diff --git a/ansible/library/deb_alternatives.py b/ansible/library/deb_alternatives.py
new file mode 100644
index 0000000000000000000000000000000000000000..21dafd4b8bcb2f913aaa771ded79ecee4721d442
--- /dev/null
+++ b/ansible/library/deb_alternatives.py
@@ -0,0 +1,116 @@
+#!/usr/bin/python
+
+# based on
+# https://github.com/ansible-collections/community.general/blob/main/plugins/modules/system/alternatives.py
+
+import os
+import re
+import subprocess
+from ansible.module_utils.basic import AnsibleModule
+
+def main():
+
+    fields = {
+      "name": {"required": True, "type": "str"},
+      "path": {"required": True, "type": "path"},
+      "link": {"type": "path"},
+      "priority": {"type": "int", "default": 50},
+      "state": {"default": "set", "choices": ["present", "set", "auto"], "type": "str"},
+      "slaves": {"type": "list", "default": []}
+    }
+    module = AnsibleModule(argument_spec=fields, supports_check_mode=True)
+
+    params = module.params
+    name = params['name']
+    path = params['path']
+    link = params['link']
+    priority = params['priority']
+    state = params["state"]
+    slaves = params['slaves']
+
+    UPDATE_ALTERNATIVES = module.get_bin_path('update-alternatives', True)
+
+    current_path = None
+    all_alternatives = []
+
+    # Run `update-alternatives --display <name>` to find existing alternatives
+    (rc, display_output, dummy) = module.run_command(
+        ['env', 'LC_ALL=C', UPDATE_ALTERNATIVES, '--display', name]
+    )
+
+    if rc == 0:
+        # Alternatives already exist for this link group
+        # Parse the output to determine the current path of the symlink and
+        # available alternatives
+        current_path_regex = re.compile(r'^\s*link currently points to (.*)$',
+                                        re.MULTILINE)
+        alternative_regex = re.compile(r'^(\/.*)\s-\s(?:family\s\S+\s)?priority', re.MULTILINE)
+
+        match = current_path_regex.search(display_output)
+        if match:
+            current_path = match.group(1)
+        all_alternatives = alternative_regex.findall(display_output)
+
+        if not link:
+            # Read the current symlink target from `update-alternatives --query`
+            # in case we need to install the new alternative before setting it.
+            #
+            # This is only compatible on Debian-based systems, as the other
+            # alternatives don't have --query available
+            rc, query_output, dummy = module.run_command(
+                ['env', 'LC_ALL=C', UPDATE_ALTERNATIVES, '--query', name]
+            )
+            if rc == 0:
+                for line in query_output.splitlines():
+                    if line.startswith('Link:'):
+                        link = line.split()[1]
+                        break
+
+    if current_path != path or len(slaves):  # TODO: properly check changed status in presence of slaves
+        if module.check_mode:
+            module.exit_json(changed=True, current_path=current_path, slaves=slaves)
+        try:
+            # install the requested path if necessary
+            if path not in all_alternatives or len(slaves):
+                if not os.path.exists(path):
+                    module.fail_json(msg="Specified path %s does not exist" % path)
+                if not link:
+                    module.fail_json(msg="Needed to install the alternative, but unable to do so as we are missing the link")
+
+                cmd_slaves = []
+                SLAVE_KEYS = ["link", "name", "path"]
+                for slave in slaves:
+                    for key in SLAVE_KEYS:
+                        if key not in slave:
+                            module.fail_json(msg="Key %s missing from slave list, required keys are: %s"
+                                             % (key, ", ".join(SLAVE_KEYS)))
+                    cmd_slaves.extend(["--slave", slave["link"], slave["name"], slave["path"]])
+
+                module.run_command(
+                    [UPDATE_ALTERNATIVES, '--install', link, name, path, str(priority)] + cmd_slaves,
+                    check_rc=True
+                )
+
+            if state == "set":
+                # select the requested path
+                module.run_command(
+                    [UPDATE_ALTERNATIVES, '--set', name, path],
+                    check_rc=True
+                )
+
+            elif state == "auto":
+                # set the path to auto mode
+                module.run_command(
+                    [UPDATE_ALTERNATIVES, '--auto', name],
+                    check_rc=True
+                )
+
+            module.exit_json(changed=True, slaves=slaves)
+        except subprocess.CalledProcessError as cpe:
+            module.fail_json(msg=str(dir(cpe)))
+    else:
+        module.exit_json(changed=False, slaves=slaves)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/ansible/roles/ghc_udeb/tasks/install.yml b/ansible/roles/ghc_udeb/tasks/install.yml
new file mode 100644
index 0000000000000000000000000000000000000000..322011f48fc397b8d78535cf6c788fc2ab42f547
--- /dev/null
+++ b/ansible/roles/ghc_udeb/tasks/install.yml
@@ -0,0 +1,125 @@
+---
+- name: Does GHC exist?
+  stat:
+    path: "/opt/ghc/{{version.ghc}}/bin/ghci-{{version.ghc}}"
+  register: ghc
+
+- name: Does Cabal exist?
+  stat:
+    path: "/opt/cabal/{{version.cabal}}/bin/cabal"
+  when: version.cabal is defined
+  register: cabal
+
+- name: GHC Dir
+  file:
+    state: directory
+    path: "/opt/ghc/{{version.ghc}}"
+
+- name: Cabal Dir
+  file:
+    state: directory
+    path: "/opt/cabal/{{version.cabal}}/bin"
+  when: version.cabal is defined
+
+- name: Build GHC & cabal if it does not exist
+  block:
+    - name: Build GHC under the builder user
+      block:
+        - name: "Download GHC"
+          unarchive:
+            src: 'https://downloads.haskell.org/~ghc/{{version.ghc}}/ghc-{{version.ghc}}-x86_64-deb9-linux.tar.xz'
+            dest: "/home/builder/ghc/"
+            remote_src: True
+
+        - name: Build GHC
+          shell:
+            chdir: '/home/builder/ghc/ghc-{{version.ghc}}'
+            cmd: "./configure --prefix=/opt/ghc/{{version.ghc}}/"
+      become_user: builder
+      become: true
+
+    - name: Install GHC
+      shell:
+        chdir: '/home/builder/ghc/ghc-{{version.ghc}}'
+        cmd: make install -j8
+
+  when: not ghc.stat.exists
+
+- name: Add cabal
+  unarchive:
+    src: "https://downloads.haskell.org/~cabal/cabal-install-{{version.cabal}}/cabal-install-{{version.cabal}}-x86_64-linux-deb10.tar.xz"
+    dest: "/opt/cabal/{{version.cabal}}/bin/"
+    remote_src: true
+  when: version.cabal is defined and not cabal.stat.exists
+
+- name: Setup opt-GHC alteranative
+  deb_alternatives:
+    name: "opt-ghc{{item}}"
+    link: "/opt/ghc/bin/ghc{{item}}"
+    path: "/opt/ghc/{{version.ghc}}/bin/ghc"
+    priority: "{{version_idx + 100}}"
+    state: present
+    slaves:
+      - link: "/opt/ghc/bin/ghci{{item}}"
+        name: "opt-ghci{{item}}"
+        path: "/opt/ghc/{{version.ghc}}/bin/ghci"
+      - link: "/opt/ghc/bin/runghc{{item}}"
+        name: "opt-runghc{{item}}"
+        path: "/opt/ghc/{{version.ghc}}/bin/runghc"
+      - link: "/opt/ghc/bin/ghc-pkg{{item}}"
+        name: "opt-ghc-pkg{{item}}"
+        path: "/opt/ghc/{{version.ghc}}/bin/ghc-pkg"
+      - link: "/opt/ghc/bin/haddock{{item}}"
+        name: "opt-haddock{{item}}"
+        path: "/opt/ghc/{{version.ghc}}/bin/haddock"
+  loop:
+    - ""
+    - "-{{version.ghc}}"
+
+- name: Setup GHC alternative
+  deb_alternatives:
+    name: "ghc{{item}}"
+    link: "/usr/bin/ghc{{item}}"
+    path: "/etc/alternatives/opt-ghc{{item}}"
+    priority: 100
+    state: present
+    slaves:
+      - link: "/usr/bin/ghci{{item}}"
+        name: "ghci{{item}}"
+        path: "/etc/alternatives/opt-ghci{{item}}"
+      - link: "/usr/bin/runghc{{item}}"
+        name: "runghc{{item}}"
+        path: "/etc/alternatives/opt-runghc{{item}}"
+      - link: "/usr/bin/ghc-pkg{{item}}"
+        name: "ghc-pkg{{item}}"
+        path: "/etc/alternatives/opt-ghc-pkg{{item}}"
+      - link: "/usr/bin/haddock{{item}}"
+        name: "haddock{{item}}"
+        path: "/etc/alternatives/opt-haddock{{item}}"
+  loop:
+    - ""
+    - "-{{version.ghc}}"
+
+- name: Setup opt-cabal alternative
+  deb_alternatives:
+    name: "opt-cabal{{item}}"
+    link: "/opt/ghc/bin/cabal{{item}}"
+    path: "/opt/cabal/{{version.cabal}}/bin/cabal"
+    priority: "{{version_idx + 100}}"
+    state: present
+  loop:
+    - ""
+    - "-{{version.cabal}}"
+  when: version.cabal is defined
+
+- name: Setup cabal alternative
+  deb_alternatives:
+    name: "cabal{{item}}"
+    link: "/usr/bin/cabal{{item}}"
+    path: "/etc/alternatives/opt-cabal{{item}}"
+    priority: 100
+    state: present
+  loop:
+    - ""
+    - "-{{version.cabal}}"
+  when: version.cabal is defined
diff --git a/ansible/roles/ghc_udeb/tasks/main.yml b/ansible/roles/ghc_udeb/tasks/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..77eef4f5f250b0f15edc871114d062920eabcb18
--- /dev/null
+++ b/ansible/roles/ghc_udeb/tasks/main.yml
@@ -0,0 +1,19 @@
+---
+- name: Builder user
+  user:
+    name: builder
+    create_home: true
+    shell: /bin/bash
+    system: true
+
+- name: GHC source dir
+  file:
+    state: directory
+    owner: "builder"
+    path: "/home/builder/ghc"
+
+- include_tasks: install.yml
+  loop: "{{versions}}"
+  loop_control:
+    loop_var: version
+    index_var: version_idx