diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f12a535452b3040f0bd8c0efc2ccf8dc5313c758..800b49474c02df4399206e97b9b9cc935b5f717b 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -16,8 +16,9 @@ stages:
   - execute
   - cleanup
 
-before_script:
-  - cp $SSH_PRIVATE_KEY id_rsa
+.with-rsa-key:
+  before_script:
+    - cp $SSH_PRIVATE_KEY id_rsa
 
 fmt:
   tags:
@@ -29,13 +30,17 @@ validate:
   tags:
     - linux
     - small
-  extends: .terraform:validate
+  extends:
+    - .terraform:validate
+    - .with-rsa-key
 
 build:
   tags:
     - linux
     - small
-  extends: .terraform:build
+  extends:
+    - .terraform:build
+    - .with-rsa-key
   environment:
     name: $TF_STATE_NAME
 
@@ -43,23 +48,64 @@ deploy:
   tags:
     - linux
     - small
-  extends: .terraform:deploy
+  extends:
+    - .terraform:deploy
+    - .with-rsa-key
   dependencies:
     - build
   rules:
-    - when: manual
 
-execute:
+execute linux:
   stage: execute
   image: alpine
   tags:
     - terraform
     - docker
   script:
-    - echo Greetings from runner!
+    - apk add gcc musl-dev
+    - gcc -o hello_world.linux hello_world.c
+  artifacts:
+    paths:
+      - hello_world.linux
+
+execute windows:
+  stage: execute
+  image: alpine
+  tags:
+    - terraform
+    - windows
+  script:
+    # The trick for running cmd scripts from powershell runner is documented
+    # here:
+    # https://gitlab.com/guided-explorations/microsoft/windows/call-cmd-from-powershell/-/blob/master/.gitlab-ci.yml
+    - |
+      set-content $env:public\inline.cmd -Value @'
+      call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat"
+      cl /Fe:hello_world.exe hello_world.c
+      '@
+      CMD.EXE /C $env:public\inline.cmd
+      exit $LASTEXITCODE
+  artifacts:
+    paths:
+      - hello_world.exe
+
+execute macos:
+  stage: execute
+  image: alpine
+  tags:
+    - terraform
+    - macos
+  script:
+    - clang -o hello_world.macos hello_world.c
+  artifacts:
+    paths:
+      - hello_world.macos
 
 destroy:
   tags:
     - linux
     - small
-  extends: .terraform:destroy
+  extends:
+    - .terraform:destroy
+    - .with-rsa-key
+
diff --git a/CHANGES.md b/CHANGES.md
index 9593c7a4517ba08f3a5d16231fb4922f8f8a0bd2..de05d2f7afb269c03be5b75b076e6e4f5e4607c9 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,7 @@
+# 2023-02-28
+
+- !4 Add Windows and Mac OS X runners
+
 # 2023-02-22
 
 - !3 Use cloud-init configuration file instead of shell script
diff --git a/README.md b/README.md
index 775745a84f1ed1791bdd47c23aa544c05b8c2946..a05fdd96fc8a777c93cc55f75d9c840bf0449b9d 100644
--- a/README.md
+++ b/README.md
@@ -174,20 +174,30 @@ The value of the `SSH_PUBLIC_KEY` variable will be stored in the file
 connect to the virtual machines with the private key to unregister the
 runners before destroying the machines.
 
-In this example, we set up only one resource:
+In this example, we set up three resources:
+a virtual machine running on Ubuntu 20.04,
+a virtual machine running on Windows 10, and
+a virtual machine running on Mac OS X 15.
+The three virtual machines register themselves as runners on gitlab.inria.fr:
+the Ubuntu machine provides a `docker` executor,
+Windows and Mac OS X provide `shell` executors
+(`powershell` for Windows, `bash` for Mac OS X).
+
+### Ubuntu 20.04 virtual machine
+
 ```terraform
-resource "cloudstack_instance" "custom_instance" {
+resource "cloudstack_instance" "ubuntu" {
   ## It is a good practice to have the "{project name}-" prefix
   ## in VM names.
-  name             = "gitlabcigallery-terraform-custom-instance"
+  name             = "gitlabcigallery-terraform-ubuntu"
   service_offering = "Custom"
   template         = "ubuntu-20.04-lts"
   zone             = "zone-ci"
   details = {
-    cpuNumber = 4
+    cpuNumber = 2
     memory    = 2048
   }
-  expunge   = true
+  expunge = true
   user_data = templatefile("cloud-init.yaml.tftpl", {
     REGISTRATION_TOKEN = var.REGISTRATION_TOKEN
     SSH_PUBLIC_KEY     = var.SSH_PUBLIC_KEY
@@ -210,7 +220,7 @@ resource "cloudstack_instance" "custom_instance" {
 
 - `custom_instance` is an identifier for the resource, which can be
   used to refer to it elsewhere in the Terraform configuration;
-- `gitlabcigallery-terraform-custom-instance` is the name of the
+- `gitlabcigallery-terraform-ubuntu` is the name of the
   virtual machine: by convention, the prefix `gitlabcigallery-terraform`
   is the name of the project on ci.inria.fr.
 - The service offering `Custom` allows us to specify the characterics
@@ -221,10 +231,13 @@ resource "cloudstack_instance" "custom_instance" {
   The available templates can be listed with the ci.inria.fr portal in the
   virtual machine creation form
   ([portal documentation](https://ci.inria.fr/doc/page/web_portal_tutorial/#slaves)).
-  The template should be configured for [`cloud-init`](https://cloud-init.io/)
-  to take into account the CloudStack user-data
+  We rely here on the fact that [`cloud-init`](https://cloud-init.io/)
+  is installed in the template and takes into account the CloudStack user-data
   ([CloudStack documentation for cloud-init support](https://docs.cloudstack.apache.org/projects/cloudstack-administration/en/latest/virtual_machines.html#user-data-and-meta-data)).
-  Templates will be progressively updated to be configured as such by default.
+  We could also use a `remote-exec` provisioner to connect the virtual machine
+  via SSH to execute an initialization script on first boot: we will use
+  this method with Windows and Mac OS X virtual machines, since `cloud-init`
+  only exists on Linux.
 - There is only one zone, `zone-ci`, and `expunge` should be set to
   `true` to ask CloudStack to destroy the virtual machine immediately
   when Terraform needs to replace it (by default, virtual machines are
@@ -251,6 +264,105 @@ resource "cloudstack_instance" "custom_instance" {
   in case of the `gitlab-runner` command was not yet installed
   when destroying occurs.
 
+### Windows 10 virtual machine
+
+```terraform
+resource "cloudstack_instance" "windows" {
+  ## It is a good practice to have the "{project name}-" prefix
+  ## in VM names.
+  name             = "gitlabcigallery-terraform-windows"
+  service_offering = "Custom"
+  template         = "windows10-vs2022-runner"
+  zone             = "zone-ci"
+  details = {
+    cpuNumber = 2
+    memory    = 2048
+  }
+  expunge = true
+  connection {
+    type                = "ssh"
+    host                = self.name
+    user                = "ci"
+    password            = "ci"
+    bastion_host        = "ci-ssh.inria.fr"
+    bastion_user        = "gter001"
+    bastion_private_key = file("id_rsa")
+    target_platform     = "windows"
+  }
+  provisioner "remote-exec" {
+    inline = [<<-EOF
+      gitlab-runner start
+      gitlab-runner register --non-interactive --tag-list terraform,windows --executor shell --shell powershell --url https://gitlab.inria.fr --registration-token ${var.REGISTRATION_TOKEN}
+      EOF
+    ]
+  }
+  provisioner "remote-exec" {
+    when   = destroy
+    inline = ["gitlab-runner unregister --all-runners || true"]
+  }
+}
+```
+
+- It is essential to specify `target_platform = "windows"` for the SSH
+  connection to work.
+
+- `gitlab-runner` service is not started on boot by default in the
+  template: we start the service explicitely before registering the runner.
+
+### Mac OS X 15 virtual machine
+
+```terraform
+resource "cloudstack_instance" "macos" {
+  ## It is a good practice to have the "{project name}-" prefix
+  ## in VM names.
+  name             = "gitlabcigallery-terraform-macos"
+  service_offering = "Custom"
+  template         = "osx-15-runner"
+  zone             = "zone-ci"
+  details = {
+    cpuNumber = 2
+    memory    = 2048
+  }
+  expunge = true
+  connection {
+    type                = "ssh"
+    host                = self.name
+    user                = "ci"
+    password            = "ci"
+    bastion_host        = "ci-ssh.inria.fr"
+    bastion_user        = "gter001"
+    bastion_private_key = file("id_rsa")
+  }
+  provisioner "remote-exec" {
+    inline = [<<-EOF
+      set -ex
+      (
+        export PATH=/usr/local/bin:$PATH
+        gitlab-runner register --non-interactive --tag-list terraform,macos --executor shell --url https://gitlab.inria.fr --registration-token ${var.REGISTRATION_TOKEN}
+      ) >~/log.txt 2>&1
+      EOF
+    ]
+  }
+  provisioner "remote-exec" {
+    when = destroy
+    inline = [<<-EOF
+      export PATH=/usr/local/bin:$PATH
+      gitlab-runner unregister --all-runners || true"
+      EOF
+    ]
+  }
+}
+```
+
+- In the `remote-exec` provisioner, outputs are redirected to `~/log.txt` to ease
+  debugging, since they are not shown in GitLab log.
+
+- The executable `gitlab-runner` is in `/usr/local/bin`, which is added to `PATH`
+  by `.bashrc` (or `.zshrc`), which is not sourced when commands are executed via SSH
+  non interactively.
+  Therefore, we add `/usr/local/bin` specifically to `PATH` before executing
+  `gitlab-runner` (we could have sourced `. ~/.bashrc` instead).
+
 ## The cloud-init configuration file [`cloud-init.yaml.tftpl`](cloud-init.yaml.tftpl)
 
 The cloud-init configuration file
@@ -286,13 +398,14 @@ The container is available `registry.gitlab.inria.fr`
 (that we use for `CI_TEMPLATE_REGISTRY_HOST`),
 in the project `gitlab-org/terraform-images`.
 
-There are four stages:
+There are five stages:
 ```yaml
 stages:
   - validate
   - build
   - deploy
   - execute
+  - destroy
 ```
 
 - In the stage `validate`, the step `validate` checks that there is no
@@ -315,6 +428,12 @@ stages:
   the same project, but we could have chosen to register the runner to
   another project by adjusting the `REGISTRATION_TOKEN` variable.)
 
+- In the stage `destroy`, the homonymous step is to be run manually and
+  executes `gitlab-terraform destroy`,
+  which destroys the virtual machines (and these virtual machines have
+  `remote-exec` destroy provisioners that unregister themselves from
+  gitlab.inria.fr).
+
 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,
diff --git a/hello_world.c b/hello_world.c
new file mode 100644
index 0000000000000000000000000000000000000000..d2f0b5d36412e031576ba01393ce554ca47fe9b3
--- /dev/null
+++ b/hello_world.c
@@ -0,0 +1,8 @@
+#include <stdio.h>
+
+int
+main(int argc, char *argv[])
+{
+  printf("Hello, world!");
+  return 0;
+}
diff --git a/main.tf b/main.tf
index ff8aecfba8a1918702c614aa51bda89845d39c1d..93085fb2cf6830f25f1608db0cc4032c79f4ad3a 100644
--- a/main.tf
+++ b/main.tf
@@ -13,26 +13,27 @@ provider "cloudstack" {
   ## Provided by environment (Gitlab secrets)
   # api_key = "${var.cloudstack_api_key}"
   # secret_key = "${var.cloudstack_secret_key}"
+
+  timeout = 1200
 }
 
 variable "REGISTRATION_TOKEN" {
-  type      = string
-  sensitive = true
+  type = string
 }
 
 variable "SSH_PUBLIC_KEY" {
   type = string
 }
 
-resource "cloudstack_instance" "custom_instance" {
+resource "cloudstack_instance" "ubuntu" {
   ## It is a good practice to have the "{project name}-" prefix
   ## in VM names.
-  name             = "gitlabcigallery-terraform-custom-instance"
+  name             = "gitlabcigallery-terraform-ubuntu"
   service_offering = "Custom"
   template         = "ubuntu-20.04-lts"
   zone             = "zone-ci"
   details = {
-    cpuNumber = 4
+    cpuNumber = 2
     memory    = 2048
   }
   expunge = true
@@ -54,3 +55,79 @@ resource "cloudstack_instance" "custom_instance" {
     inline = ["sudo gitlab-runner unregister --all-runners || true"]
   }
 }
+
+resource "cloudstack_instance" "windows" {
+  ## It is a good practice to have the "{project name}-" prefix
+  ## in VM names.
+  name             = "gitlabcigallery-terraform-windows"
+  service_offering = "Custom"
+  template         = "windows10-vs2022-runner"
+  zone             = "zone-ci"
+  details = {
+    cpuNumber = 2
+    memory    = 2048
+  }
+  expunge = true
+  connection {
+    type                = "ssh"
+    host                = self.name
+    user                = "ci"
+    password            = "ci"
+    bastion_host        = "ci-ssh.inria.fr"
+    bastion_user        = "gter001"
+    bastion_private_key = file("id_rsa")
+    target_platform     = "windows"
+  }
+  provisioner "remote-exec" {
+    inline = [<<-EOF
+      gitlab-runner start
+      gitlab-runner register --non-interactive --tag-list terraform,windows --executor shell --shell powershell --url https://gitlab.inria.fr --registration-token ${var.REGISTRATION_TOKEN}
+      EOF
+    ]
+  }
+  provisioner "remote-exec" {
+    when   = destroy
+    inline = ["gitlab-runner unregister --all-runners || true"]
+  }
+}
+
+resource "cloudstack_instance" "macos" {
+  ## It is a good practice to have the "{project name}-" prefix
+  ## in VM names.
+  name             = "gitlabcigallery-terraform-macos"
+  service_offering = "Custom"
+  template         = "osx-15-runner"
+  zone             = "zone-ci"
+  details = {
+    cpuNumber = 2
+    memory    = 2048
+  }
+  expunge = true
+  connection {
+    type                = "ssh"
+    host                = self.name
+    user                = "ci"
+    password            = "ci"
+    bastion_host        = "ci-ssh.inria.fr"
+    bastion_user        = "gter001"
+    bastion_private_key = file("id_rsa")
+  }
+  provisioner "remote-exec" {
+    inline = [<<-EOF
+      set -ex
+      (
+        export PATH=/usr/local/bin:$PATH
+        gitlab-runner register --non-interactive --tag-list terraform,macos --executor shell --url https://gitlab.inria.fr --registration-token ${var.REGISTRATION_TOKEN}
+      ) >~/log.txt 2>&1
+      EOF
+    ]
+  }
+  provisioner "remote-exec" {
+    when = destroy
+    inline = [<<-EOF
+      export PATH=/usr/local/bin:$PATH
+      gitlab-runner unregister --all-runners || true"
+      EOF
+    ]
+  }
+}