diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 85ce6e3426aee3d6c8c8ac3c4ae7726096c5d71e..f12a535452b3040f0bd8c0efc2ccf8dc5313c758 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,3 +1,7 @@
+workflow:
+  rules:
+    - if: $CLOUDSTACK_API_KEY
+
 variables:
   CI_TEMPLATE_REGISTRY_HOST: registry.gitlab.inria.fr
   TF_STATE_NAME: default
@@ -10,6 +14,10 @@ stages:
   - build
   - deploy
   - execute
+  - cleanup
+
+before_script:
+  - cp $SSH_PRIVATE_KEY id_rsa
 
 fmt:
   tags:
@@ -22,8 +30,6 @@ validate:
     - linux
     - small
   extends: .terraform:validate
-  before_script:
-    - cp $SSH_PRIVATE_KEY id_rsa
 
 build:
   tags:
@@ -32,8 +38,6 @@ build:
   extends: .terraform:build
   environment:
     name: $TF_STATE_NAME
-  before_script:
-    - cp $SSH_PRIVATE_KEY id_rsa
 
 deploy:
   tags:
@@ -42,6 +46,8 @@ deploy:
   extends: .terraform:deploy
   dependencies:
     - build
+  rules:
+    - when: manual
 
 execute:
   stage: execute
@@ -49,7 +55,11 @@ execute:
   tags:
     - terraform
     - docker
-  rules:
-    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
   script:
     - echo Greetings from runner!
+
+destroy:
+  tags:
+    - linux
+    - small
+  extends: .terraform:destroy
diff --git a/CHANGES.md b/CHANGES.md
index bbb7eb032b0a36b9863bca4e6c2f1a29fe7f8fbc..9593c7a4517ba08f3a5d16231fb4922f8f8a0bd2 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,7 @@
+# 2023-02-22
+
+- !3 Use cloud-init configuration file instead of shell script
+
 # 2023-02-13
 
 - !1 Unregister gitlab runner on VM destruction
diff --git a/README.md b/README.md
index aa3d0325d1c677b89f366a1dda5335f439f956fe..759da1c8fec83f60d5f6c6c1b6582b19d6c5d8df 100644
--- a/README.md
+++ b/README.md
@@ -181,7 +181,7 @@ resource "cloudstack_instance" "custom_instance" {
     memory    = 2048
   }
   expunge   = true
-  user_data = templatefile("cloud-init.sh.tftpl", {
+  user_data = templatefile("cloud-init.yaml.tftpl", {
     REGISTRATION_TOKEN = var.REGISTRATION_TOKEN
     SSH_PUBLIC_KEY     = var.SSH_PUBLIC_KEY
   })
@@ -226,8 +226,8 @@ resource "cloudstack_instance" "custom_instance" {
 - `user_data` contains a script which is passed to
   [`cloud-init`](https://cloud-init.io/)
   to be run at the first boot of the virtual machine. The `templatefile`
-  is used to read the script from file
-  [`cloud-init.sh.tftpl`](cloud-init.sh.tftpl)
+  is used to read the cloud-init configuration file from file
+  [`cloud-init.yaml.tftpl`](cloud-init.yaml.tftpl)
   by substituting `${REGISTRATION_TOKEN}` with the value of the variable
   passed to Terraform.
 - We pass also the `SSH_PUBLIC_KEY` to the template file to have its
@@ -244,10 +244,23 @@ resource "cloudstack_instance" "custom_instance" {
   in case of the `gitlab-runner` command was not yet installed
   when destroying occurs.
 
-The script [`cloud-init.sh.tftpl`](cloud-init.sh.tftpl) registers
-the SSH public key in `~ci/.ssh/authorized_keys`,
-installs `gitlab-runner` and `docker.io` on the virtual machine,
-and registers the runner.
+## The cloud-init configuration file [`cloud-init.yaml.tftpl`](cloud-init.yaml.tftpl)
+
+The cloud-init configuration file
+[`cloud-init.yaml.tftpl`](cloud-init.yaml.tftpl) sets up the following:
+- the user `ci` can execute `sudo` without password, so that the destroy
+provisioner be able to unregister the runners;
+- the SSH public key is registed as authorized key for `ci`,
+- by default, password authentication is disabled for `ci`
+  (you may add `lock_passwd: false` to enable it again,
+  [documentation](https://cloudinit.readthedocs.io/en/latest/reference/modules.html#users-and-groups));
+- `gitlab-runner` and `docker.io` is installed on the virtual machine,
+  the runner is registered on gitlab.inria.fr.
+The configuration file should begin with the following line.
+```yaml
+#cloud-config
+```
+You may provide a shell script with the according shebang (`#!/bin/sh`) instead.
 
 ## The pipeline specification file [`.gitlab-ci.yml`](.gitlab-ci.yml)
 
@@ -298,16 +311,13 @@ stages:
 Every job that will use the Terraform configuration file needs to copy
 the file referred by `SSH_PRIVATE_KEY` into the file `id_rsa`.
 To copy the file without overriding all the script,
-we use the `before_script` key. For instance, in the job `validate`:
+we use the `before_script` key:
+defining the `before_script` key at top-level, outside any job,
+makes the file be copied before every job.
 
 ```yaml
-validate:
-  tags:
-    - linux
-    - small
-  extends: .terraform:validate
-  before_script:
-    - cp $SSH_PRIVATE_KEY id_rsa
+before_script:
+  - cp $SSH_PRIVATE_KEY id_rsa
 ```
 
 ## Ignored files in [`.gitignore`](.gitignore)
diff --git a/cloud-init.sh.tftpl b/cloud-init.sh.tftpl
deleted file mode 100644
index fdcee37708eebf34dfa2458efbcb0749a7699a3b..0000000000000000000000000000000000000000
--- a/cloud-init.sh.tftpl
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/bin/bash
-# Standard output and errors are redirected to /root/log.txt to ease
-# debugging.
-(
-  # To be able to run `sudo gitlab-runner unregister --all-runners` on
-  # VM destruction.
-  echo 'ci ALL=(ALL) NOPASSWD:ALL' >/etc/sudoers.d/90-ci
-  mkdir -p -m 700 ~ci/.ssh
-  echo ${SSH_PUBLIC_KEY} >>~ci/.ssh/authorized_keys
-  chown -R ci:ci ~ci/.ssh
-  # GitLab needs a recent version of `gitlab-runner` to be compatible with
-  # the instance running on gitlab.inria.fr. The version packaged by default
-  # on Ubuntu is regularly out of date.
-  curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
-  # apt-get update performed by the script above
-  # We install docker.io to be able to register a docker executor
-  apt-get install --yes gitlab-runner docker.io
-  gitlab-runner register --non-interactive --tag-list terraform,docker \
-    --executor docker --docker-image alpine --url https://gitlab.inria.fr \
-    --registration-token ${REGISTRATION_TOKEN}
-) >>/root/log.txt 2>&1
diff --git a/cloud-init.yaml.tftpl b/cloud-init.yaml.tftpl
new file mode 100644
index 0000000000000000000000000000000000000000..f12418a0b520a20715b5a782b0845153eecdac30
--- /dev/null
+++ b/cloud-init.yaml.tftpl
@@ -0,0 +1,14 @@
+#cloud-config
+users:
+  - name: ci
+    sudo: ALL=(ALL) NOPASSWD:ALL
+    ssh_authorized_keys:
+      - ${SSH_PUBLIC_KEY}
+
+runcmd:
+  - curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
+  - apt-get install --yes gitlab-runner docker.io
+  - |
+    gitlab-runner register --non-interactive --tag-list terraform,docker \
+      --executor docker --docker-image alpine --url https://gitlab.inria.fr \
+      --registration-token ${REGISTRATION_TOKEN}
diff --git a/main.tf b/main.tf
index dca84281a5193fdad2c9f5115c55a13d2a1d7da1..ff8aecfba8a1918702c614aa51bda89845d39c1d 100644
--- a/main.tf
+++ b/main.tf
@@ -36,7 +36,7 @@ resource "cloudstack_instance" "custom_instance" {
     memory    = 2048
   }
   expunge = true
-  user_data = templatefile("cloud-init.sh.tftpl", {
+  user_data = templatefile("cloud-init.yaml.tftpl", {
     REGISTRATION_TOKEN = var.REGISTRATION_TOKEN
     SSH_PUBLIC_KEY     = var.SSH_PUBLIC_KEY
   })