{"id":422,"date":"2026-09-01T13:22:52","date_gmt":"2026-09-01T13:22:52","guid":{"rendered":"https:\/\/www.vps.tc\/blog\/?p=422"},"modified":"2026-09-01T09:50:05","modified_gmt":"2026-09-01T09:50:05","slug":"what-is-cloud-init-automate-vps-provisioning","status":"publish","type":"post","link":"https:\/\/www.vps.tc\/blog\/en\/what-is-cloud-init-automate-vps-provisioning\/","title":{"rendered":"What Is Cloud-Init? Automate VPS Provisioning"},"content":{"rendered":"<div class=\"aiw-toc\" style=\"border:1px solid #dbe3ea;border-radius:8px;padding:16px 20px;margin:0 0 28px\"><strong>Table of Contents<\/strong><\/p>\n<ol style=\"margin:10px 0 0;padding-left:22px\">\n<li><a href=\"#the-first-boot-is-where-cloud-init-earns-its-keep\">The first boot is where cloud-init earns its keep<\/a><\/li>\n<li><a href=\"#what-happens-during-first-boot\">What happens during first boot?<\/a><\/li>\n<li><a href=\"#cloud-images-metadata-and-user-data\">Cloud images, metadata, and user-data<\/a><\/li>\n<li><a href=\"#your-first-useful-user-data-file\">Your first useful user-data file<\/a><\/li>\n<li><a href=\"#writing-files-starting-services-and-runcmd-traps\">Writing files, starting services, and runcmd traps<\/a><\/li>\n<li><a href=\"#cloud-init-is-not-a-traditional-install-script\">Cloud-init is not a traditional install script<\/a><\/li>\n<li><a href=\"#when-cloud-init-appears-to-fail\">When cloud-init appears to fail<\/a><\/li>\n<li><a href=\"#security-details-that-matter-in-production\">Security details that matter in production<\/a><\/li>\n<li><a href=\"#a-realistic-vps-workflow\">A realistic VPS workflow<\/a><\/li>\n<li><a href=\"#which-jobs-belong-in-cloud-init\">Which jobs belong in cloud-init?<\/a><\/li>\n<li><a href=\"#a-sensible-place-to-start\">A sensible place to start<\/a><\/li>\n<li><a href=\"#frequently-asked-questions\">Frequently asked questions<\/a><\/li>\n<\/ol>\n<\/div>\n<h2 id=\"the-first-boot-is-where-cloud-init-earns-its-keep\">The first boot is where cloud-init earns its keep<\/h2>\n<p>A new VPS rarely begins with one big task. It begins with fifteen small ones: update the package index, create an administration user, install an SSH key, set the timezone, prepare swap, and install the few services you need. Doing that once is fine. Doing it for the tenth VPS is how a missed option or a copied command turns into an incident.<\/p>\n<p>Cloud-init handles this first layer. It is a service that applies operating system and user configuration when a cloud server boots for the first time. Cloud-init reads metadata supplied by your provider and the <code>user-data<\/code> you provide, then configures things such as the hostname, users, SSH keys, network settings, packages, and first-boot commands.<\/p>\n<p>There is one detail worth keeping clear: cloud-init usually does not install the operating system. The provider&#8217;s cloud image has already been written to disk and prepared for its first boot. Cloud-init turns that prepared image into the particular server you requested.<\/p>\n<p>When I first used it, I thought cloud-init was basically a small Ansible file. I was wrong. Cloud-init runs while the machine is completing its first boot; Ansible normally connects later, once SSH is available. They solve related problems, but they start from different places.<\/p>\n<h2 id=\"what-happens-during-first-boot\">What happens during first boot?<\/h2>\n<p>The exact ordering varies with the distribution, cloud-init release, and datasource, but the service works through several stages. Knowing the stages makes questions such as &#8220;why was the user not created?&#8221; much easier to investigate.<\/p>\n<ul>\n<li><strong>Local stage:<\/strong> The system decides whether cloud-init should run and looks for a local datasource.<\/li>\n<li><strong>Network stage:<\/strong> After the network is available, cloud-init can retrieve metadata and user-data from a datasource that requires networking.<\/li>\n<li><strong>Config stage:<\/strong> Modules configure users, SSH keys, package repositories, the hostname, and files.<\/li>\n<li><strong>Final stage:<\/strong> Commands such as <code>runcmd<\/code> are executed. If package installation is still running, this stage may finish later than you expect.<\/li>\n<\/ul>\n<p>On an Ubuntu cloud image, I usually inspect the related units with:<\/p>\n<pre><code>systemctl status cloud-init-local.service\nsystemctl status cloud-init.service\nsystemctl status cloud-config.service\nsystemctl status cloud-final.service<\/code><\/pre>\n<p>The last unit is particularly useful. On the Ubuntu images I run, <code>runcmd<\/code> commands generally execute during <code>cloud-final.service<\/code>. In one test I connected over SSH as soon as the connection became available and assumed the VPS was ready. Package installation was still in progress, so the commands I ran afterwards failed in confusing ways.<\/p>\n<p>SSH being available is not the same as first boot being finished. That distinction saves time.<\/p>\n<h2 id=\"cloud-images-metadata-and-user-data\">Cloud images, metadata, and user-data<\/h2>\n<p>Keep these three pieces separate. A cloud image is an operating system image prepared for cloud use. Metadata contains information such as the machine name, instance ID, network details, and provider-specific values. User-data is the configuration you send.<\/p>\n<p>Providers expose this information in different ways. OpenStack and many commercial cloud platforms use a metadata service. In my home Proxmox lab, I use the NoCloud datasource. With NoCloud, a seed ISO or suitable disk contains <code>user-data<\/code> and <code>meta-data<\/code> files.<\/p>\n<p>Not every virtual machine on Proxmox is automatically ready for cloud-init. You need a cloud image, a template, a cloud-init drive, and values such as the IP address, gateway, and DNS servers. That preparation is done once. Afterwards, you can create machines from the template and provide different metadata or user-data.<\/p>\n<p>I once rushed template validation and cloned a powered-off VM without testing the result. The clone booted, but its hostname and network settings were not what I expected. The serial console still showed details belonging to the old machine. The problem was not in user-data; I had used the template without cleaning it properly or opening a test clone.<\/p>\n<p>These days, every template gets a small test clone. I check its hostname, network, instance identity, and SSH access before allowing it to become a production source.<\/p>\n<h2 id=\"your-first-useful-user-data-file\">Your first useful user-data file<\/h2>\n<p>User-data is commonly written as YAML and starts with <code>#cloud-config<\/code>. YAML cares about indentation, so use spaces rather than tabs. I validate the file locally and then try it on a small test VM. One misplaced space can be enough for a module to be skipped.<\/p>\n<pre><code>#cloud-config\nhostname: web-01\nmanage_etc_hosts: true\n\nusers:\n  - name: deploy\n    gecos: Deployment User\n    groups: [adm, sudo]\n    shell: \/bin\/bash\n    sudo: \"ALL=(ALL) NOPASSWD:ALL\"\n    lock_passwd: true\n    ssh_authorized_keys:\n      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... user@laptop\n\npackage_update: true\npackage_upgrade: false\npackages:\n  - nginx\n  - curl\n  - vim\n\nwrite_files:\n  - path: \/etc\/motd\n    owner: root:root\n    permissions: '0644'\n    content: |\n      Managed by cloud-init\n\nruncmd:\n  - systemctl enable --now nginx\n  - [ sh, -c, \"echo 'cloud-init complete' &gt;&gt; \/var\/log\/first-boot.log\" ]<\/code><\/pre>\n<p>Here, the <code>deploy<\/code> user cannot log in with a password, uses an SSH key, and has sudo access. I shortened the real key for display. User-data may be stored in a provider panel, a metadata service, or a seed ISO, so never put your private key in it.<\/p>\n<p><code>package_update<\/code> refreshes package lists, while <code>packages<\/code> installs the packages you specify. Setting <code>package_upgrade: true<\/code> can upgrade the entire system during first boot. I do not enable that casually on production templates, because a kernel or critical library update may require a reboot.<\/p>\n<h3>A safer starting point for users<\/h3>\n<p>Many cloud images include a default user. On Ubuntu it is often <code>ubuntu<\/code>; on Debian images it may be <code>debian<\/code>. Rather than enabling direct SSH access for <code>root<\/code> without checking the provider&#8217;s documentation, I prefer creating a personal administration user in the first user-data file.<\/p>\n<p>To disable password-based SSH authentication, you can use:<\/p>\n<pre><code>ssh_pwauth: false\n\nusers:\n  - default\n  - name: sysadmin\n    groups: [sudo]\n    shell: \/bin\/bash\n    lock_passwd: true\n    ssh_authorized_keys:\n      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... sysadmin@workstation<\/code><\/pre>\n<p>The <code>users: - default<\/code> entry keeps the cloud image&#8217;s default user. If you remove it, you may lose the distribution&#8217;s initial access account. Changing the SSH port is not security by itself; key-based access, disabled password authentication, current packages, and firewall rules need to be considered together.<\/p>\n<h2 id=\"writing-files-starting-services-and-runcmd-traps\">Writing files, starting services, and runcmd traps<\/h2>\n<p><code>write_files<\/code> is useful when you need to place specific content in specific files during first boot. An Nginx site definition, a systemd unit, or a small application configuration can all be installed this way.<\/p>\n<pre><code>write_files:\n  - path: \/etc\/systemd\/system\/example.service\n    permissions: '0644'\n    content: |\n      [Unit]\n      Description=Example service\n      After=network-online.target\n\n      [Service]\n      Type=simple\n      ExecStart=\/usr\/local\/bin\/example\n      Restart=on-failure\n      User=deploy\n\n      [Install]\n      WantedBy=multi-user.target\n\nruncmd:\n  - [ systemctl, daemon-reload ]\n  - [ systemctl, enable, --now, example.service ]<\/code><\/pre>\n<p>Make sure the binary actually exists before starting the service. <code>write_files<\/code> creates the file; it does not install the program. <code>runcmd<\/code> records commands for execution and normally runs them during the final stage.<\/p>\n<p>Shell variables and special characters are another trap. This may not produce what you expect:<\/p>\n<pre><code>runcmd:\n  - echo $HOME &gt; \/tmp\/home.txt<\/code><\/pre>\n<p>The command is interpreted by a shell during cloud-init&#8217;s execution, not necessarily in the environment you had in mind. Make the shell and quoting explicit:<\/p>\n<pre><code>runcmd:\n  - [ sh, -c, 'printf \"%sn\" \"$HOME\" &gt; \/tmp\/home.txt' ]<\/code><\/pre>\n<p>Design commands so that running them again does not damage the system. An <code>echo ... &gt;&gt;<\/code> command that appends a line on every run creates needless duplicates if you reuse it on the same instance. I have done exactly that in a test image (the log was not dangerous, just embarrassing).<\/p>\n<p>Cloud-init is a good fit for initial setup. When you need ongoing configuration management, moving to Ansible is cleaner. You can use the steps from <a href=\"\/blog\/en\/how-to-install-docker-on-a-vps-and-run-your-first-container\/\">How to Install Docker on a VPS and Run Your First Container<\/a> as a starting point for user-data, but test when the Docker service and group changes become effective before putting that setup on a live system.<\/p>\n<h2 id=\"cloud-init-is-not-a-traditional-install-script\">Cloud-init is not a traditional install script<\/h2>\n<p>At first glance, both appear to run commands when a server starts. Cloud-init is tied to the operating system&#8217;s boot process and cloud metadata. It can use the instance ID, network information, and SSH keys supplied by the provider; it also runs modules at defined stages and keeps state for many operations.<\/p>\n<table>\n<thead>\n<tr>\n<th>Method<\/th>\n<th>Strength<\/th>\n<th>What to watch<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Cloud-init<\/td>\n<td>Sets up users, packages, and basic system configuration at first boot<\/td>\n<td>Requires a cloud image and a correctly configured datasource<\/td>\n<\/tr>\n<tr>\n<td>Shell script<\/td>\n<td>Simple and quick for a prototype<\/td>\n<td>Error handling, reruns, and ordering are your responsibility<\/td>\n<\/tr>\n<tr>\n<td>Ansible<\/td>\n<td>Repeatable, readable, and able to manage existing machines<\/td>\n<td>Requires SSH access, Python, and a separate control machine<\/td>\n<\/tr>\n<tr>\n<td>Terraform<\/td>\n<td>Defines infrastructure resources such as VPS instances and networks<\/td>\n<td>Not enough by itself for detailed operating system configuration<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>My practical workflow is usually to create the VPS with Terraform or the provider&#8217;s panel, prepare initial access and basic security with cloud-init, and then install the application layer with Ansible. For one VPS, using both YAML and Ansible can feel excessive. If you have repeated the same setup manually three times, though, the cost of another manual run is already showing.<\/p>\n<h2 id=\"when-cloud-init-appears-to-fail\">When cloud-init appears to fail<\/h2>\n<p>Logs are the first place I look. Rebooting the server often does not fix the problem; it may only make the original cause harder to see.<\/p>\n<pre><code>cloud-init status --long\ncloud-init query ds\njournalctl -u cloud-init -u cloud-config -u cloud-final --no-pager\nless \/var\/log\/cloud-init.log\nless \/var\/log\/cloud-init-output.log<\/code><\/pre>\n<p><code>cloud-init status --long<\/code> showing <code>status: done<\/code> tells you that processing finished. It does not tell you that the configuration was correct. <code>cloud-init-output.log<\/code> is especially useful for finding <code>runcmd<\/code> output and standard error from commands.<\/p>\n<p>I once saw &#8220;done&#8221;, assumed everything had succeeded, and reported that the application was ready without checking the service. Later lines in the log showed that the package repository could not resolve its DNS name. The status was accurate; my interpretation was not.<\/p>\n<p><code>cloud-init query ds<\/code> may not return identical output on every image. With NoCloud, the instance ID in the metadata file and the connection of the seed disk both matter. Cloning a snapshot or template with the same instance ID can also make cloud-init believe it has already seen the machine.<\/p>\n<p>You can check the YAML syntax locally with:<\/p>\n<pre><code>python3 - &lt;&lt;'PY'\nimport sys\ntry:\n    import yaml\nexcept ImportError:\n    print(\"PyYAML is not installed\", file=sys.stderr)\n    sys.exit(1)\nwith open(\"user-data\", encoding=\"utf-8\") as f:\n    yaml.safe_load(f)\nprint(\"YAML is valid\")\nPY<\/code><\/pre>\n<p>This only checks YAML syntax. It does not verify that the <code>sudo<\/code> value is correct, that a package exists in the distribution repository, or that a command will run as the user you intended.<\/p>\n<h3>Does it run once, or can it run again?<\/h3>\n<p>Cloud-init keeps an instance ID and state files for many modules. If you change user-data and reboot the same VPS, do not expect every setting to be applied again. During testing, you can reset it with:<\/p>\n<pre><code>sudo cloud-init clean --logs --seed\nsudo reboot<\/code><\/pre>\n<p>Do not run this randomly on a production machine. It can make cloud-init behave like a new first boot and trigger package, user, or service operations from user-data again. A method that is useful on a test VM is not a production fix without a maintenance plan.<\/p>\n<p>Creating a new instance is often safer than deleting state information from an existing machine. When preparing a template, clean cloud-init and make sure old SSH host keys and machine-specific credentials are not baked into the image.<\/p>\n<h2 id=\"security-details-that-matter-in-production\">Security details that matter in production<\/h2>\n<p>Do you know where your user-data is stored? On some cloud platforms, authorized processes can access the instance metadata service. User-data may also remain in panel history, Terraform state files, or debug output. Do not write passwords, API keys, or private SSH keys in plain text.<\/p>\n<ul>\n<li>Use key-based SSH access instead of passwords.<\/li>\n<li>Give the initial user only the sudo access it needs; limit privileged commands where practical.<\/li>\n<li>If an API key is required, use a secret manager, the provider&#8217;s secret mechanism, or a short-lived token.<\/li>\n<li>Test that firewall rules installed by cloud-init do not cut off the current SSH session.<\/li>\n<li>Keep the cloud image and cloud-init package current, but do not roll an uncontrolled upgrade across every production machine.<\/li>\n<li>Check first-boot logs to make sure they do not contain tokens, passwords, or private keys.<\/li>\n<\/ul>\n<p>If you want to automate the basic hardening steps, split the recommendations from <a href=\"\/blog\/en\/10-essential-steps-to-secure-and-harden-your-linux-server\/\">10 Essential Steps to Secure and Harden Your Linux Server<\/a> between user-data and Ansible tasks. I configure the firewall and SSH settings as early as practical with cloud-init, then manage application details later. If first boot fails, access and diagnostic paths remain easier to understand.<\/p>\n<h2 id=\"a-realistic-vps-workflow\">A realistic VPS workflow<\/h2>\n<p>Suppose you are creating a web server from an Ubuntu 24.04 cloud image. First check the provider&#8217;s cloud-init support, the accepted user-data format, and whether network settings are supplied automatically. Then begin with a small configuration: one user, one SSH key, a hostname, and one test package.<\/p>\n<pre><code>#cloud-config\nhostname: web-01\nfqdn: web-01.example.net\nmanage_etc_hosts: true\nssh_pwauth: false\npackage_update: true\npackages:\n  - nginx\n\nusers:\n  - default\n  - name: deploy\n    groups: [sudo]\n    shell: \/bin\/bash\n    lock_passwd: true\n    sudo: \"ALL=(ALL) NOPASSWD:ALL\"\n    ssh_authorized_keys:\n      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... deploy@laptop\n\nruncmd:\n  - [ systemctl, enable, --now, nginx ]\n  - [ sh, -c, \"printf '%sn' 'first boot ok' &gt; \/var\/log\/first-boot-check.log\" ]<\/code><\/pre>\n<p>After connecting to the machine, do not only check that Nginx is running. Verify that the user can log in with the key, the hostname is correct, the apt operation has finished, and cloud-init has not recorded an error:<\/p>\n<pre><code>hostnamectl\nid deploy\nsudo systemctl is-active nginx\nsudo cloud-init status --long\nsudo tail -n 30 \/var\/log\/cloud-init-output.log<\/code><\/pre>\n<p>You can turn these checks into a shell script or an Ansible verification task. For sensitive services such as MySQL, I prefer setting up the base system first and then using configuration management rather than putting a long <code>runcmd<\/code> list in one file. The steps in <a href=\"\/blog\/en\/how-to-install-and-secure-mysql-on-a-vps\/\">How to Install and Secure MySQL on a VPS<\/a> are better treated as a later configuration stage. Putting the MySQL root password in user-data is an especially bad idea.<\/p>\n<h2 id=\"which-jobs-belong-in-cloud-init\">Which jobs belong in cloud-init?<\/h2>\n<p>Tasks suited to first boot should be short, predictable, and related to basic access to the machine. Creating a user, installing an SSH key, setting the hostname and timezone, configuring package repositories, installing a few packages, and enabling a simple systemd service are good candidates.<\/p>\n<p>Putting an entire application deployment into one <code>runcmd<\/code> block creates a fragile system. Use a separate process for long Docker Compose deployments, database schemas, external-service secrets, and migrations that need rollback when they fail. Cloud-init may continue to later commands after a command fails, so handle that explicitly with shell logic.<\/p>\n<pre><code>runcmd:\n  - [ sh, -c, 'set -euxo pipefail; \/usr\/local\/bin\/bootstrap-app' ]<\/code><\/pre>\n<p><code>set -euxo pipefail<\/code> can make debugging easier, but it is not suitable blindly for every script. Variables containing secrets may be written to logs by <code>-x<\/code>. In production user-data, balance useful error visibility against confidentiality.<\/p>\n<p>When preparing an image, clean cloud-init, make sure every clone behaves like a new instance, and plan for SSH host keys to be regenerated. Rushed Proxmox operations, such as cloning before the template is properly shut down, can leave two machines showing the same hostname or old network information. I shut down the template, create a test clone, and verify it through both the serial console and SSH.<\/p>\n<h2 id=\"a-sensible-place-to-start\">A sensible place to start<\/h2>\n<p>Choose one Ubuntu or Debian cloud image offered by your provider. Trying to manage two distributions with the same user-data creates unnecessary confusion around package names and default users. Write a small file first. Do not move on to Nginx, Docker, or a database until the user and SSH key work.<\/p>\n<p>If you keep the file in version control, never commit real keys or secrets. Monitor the template&#8217;s cloud-init logs and service state. A successful first boot means more than a VPS answering ping: secure access with the expected user, the correct hostname, completed package installation, and clean logs all matter.<\/p>\n<p>For me, cloud-init is the first-boot automation layer that turns VPS provisioning into a repeatable process with less manual intervention. I do not put every job inside it. A short, tested user-data file followed by Ansible for the application layer is usually easier to understand than one enormous bootstrap script.<\/p>\n<p>I still perform the final checks. Automation does not replace a checklist; it makes the checklist less dependent on memory.<\/p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions<\/h2>\n<h3>Does cloud-init work on every VPS?<\/h3>\n<p>No. Your provider must support cloud-init, and the selected cloud image and datasource must be configured correctly. A VPS installed from a traditional ISO may not have cloud-init ready automatically.<\/p>\n<h3>Is it safe to set a password with cloud-init?<\/h3>\n<p>Keeping a password in plain text inside user-data is not safe; it may remain in panel history or a metadata service. Use SSH keys, disable password-based SSH access, and use a separate secret-management mechanism when needed.<\/p>\n<h3>Do cloud-init commands run after every reboot?<\/h3>\n<p>Usually not. Many modules use the instance ID and state records to run once during first boot. If you need to run something again, test the module behavior and the effect of <code>cloud-init clean<\/code> on a test VM first.<\/p>\n<h3>What is the difference between cloud-init and Ansible?<\/h3>\n<p>Cloud-init prepares the basic system and access during the machine&#8217;s first boot. Ansible manages existing machines over SSH with more detailed, repeatable, ongoing configuration. In practice, using them one after the other is healthier for many environments.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Cloud-init automates the first boot of a VPS, from users and SSH keys to packages, services, and basic security. Learn how to use and troubleshoot it safely.<\/p>\n","protected":false},"author":2,"featured_media":420,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[11],"tags":[1511,43,1517,1514,1520,29],"class_list":["post-422","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-cloud-computing","tag-cloud-init-en","tag-linux","tag-proxmox-en","tag-server-automation","tag-user-data-en","tag-vps"],"lang":"en","translations":{"en":422,"tr":421},"pll_sync_post":[],"_links":{"self":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts\/422","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/comments?post=422"}],"version-history":[{"count":1,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts\/422\/revisions"}],"predecessor-version":[{"id":424,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts\/422\/revisions\/424"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/media\/420"}],"wp:attachment":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/media?parent=422"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/categories?post=422"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/tags?post=422"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}