diff --git a/.gitlab/README.md.template b/.gitlab/README.md.template
index 90ed49b187ceaa084184246cf19b9fb83d6784f5..1f2172898790ba0fb387eaa83bc28efea76d2257 100644
--- a/.gitlab/README.md.template
+++ b/.gitlab/README.md.template
@@ -165,6 +165,7 @@ The following job components exist:
 - [`apply`](templates/apply.yml)
 - [`destroy`](templates/destroy.yml)
 - [`delete-state`](templates/delete-state.yml)
+- [`custom-command`](templates/custom-command.yml)
 
 Have a look at the individual template spec to learn about the available inputs.
 
diff --git a/README.md b/README.md
index 3de30a30389dcd3575471c28748e72b356c19be7..c6a8038bd93333052b2a2e9cced82dab3e16d62a 100644
--- a/README.md
+++ b/README.md
@@ -167,6 +167,7 @@ The following job components exist:
 - [`apply`](templates/apply.yml)
 - [`destroy`](templates/destroy.yml)
 - [`delete-state`](templates/delete-state.yml)
+- [`custom-command`](templates/custom-command.yml)
 
 Have a look at the individual template spec to learn about the available inputs.
 
diff --git a/templates/custom-command.yml b/templates/custom-command.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9afefe2c7b1161755ba21349c7faaec7f7fce633
--- /dev/null
+++ b/templates/custom-command.yml
@@ -0,0 +1,68 @@
+spec:
+  inputs:
+    # Job and Stage name
+    as:
+      default: 'custom'
+      description: 'Defines the name of this job.'
+    stage:
+      default: 'test'
+      description: 'Defines the stage that this job will belong to.'
+
+    # Versions
+    # This version is only required, because we cannot access the context of the component,
+    # see https://gitlab.com/gitlab-org/gitlab/-/issues/438275
+    version:
+      default: 'latest'
+      description: 'Version of this component. Has to be the same as the one in the component include entry.'
+
+    opentofu_version:
+      default: '1.6.1'
+      options:
+        - '$OPENTOFU_VERSION'
+        - '1.6.1'
+        - '1.6.0'
+        - '1.6.0-rc1'
+      description: 'OpenTofu version that should be used.'
+
+    # Images
+    image_registry_base:
+      default: '$CI_REGISTRY/components/opentofu'
+      description: 'Host URI to the job images. Will be combined with `image_name` to construct the actual image URI.'
+    # FIXME: not yet possible because of https://gitlab.com/gitlab-org/gitlab/-/issues/438722
+    # gitlab_opentofu_image:
+    #   # FIXME: This should reference the component tag that is used.
+    #   #        Currently, blocked by https://gitlab.com/gitlab-org/gitlab/-/issues/438275
+    #   # default: '$CI_REGISTRY/components/opentofu/gitlab-opentofu:$[[ inputs.opentofu_version ]]'
+    #   default: '$CI_REGISTRY/components/opentofu/gitlab-opentofu:$[[ inputs.version ]]-opentofu$[[ inputs.opentofu_version ]]'
+    #   description: 'Tag of the gitlab-opentofu image.'
+
+    image_name:
+      default: 'gitlab-opentofu'
+      description: 'Image name for the job images. Hosted under `image_registry_base`.'
+
+    # Configuration
+    root_dir:
+      default: ${CI_PROJECT_DIR}
+      description: 'Root directory for the OpenTofu project.'
+
+    command:
+      description: 'The gitlab-tofu command to run.'
+
+---
+
+'$[[ inputs.as ]]':
+  stage: $[[ inputs.stage ]]
+  needs: []
+  cache:
+    key: "$__CACHE_KEY_HACK"
+    paths:
+      - $TF_ROOT/.terraform/
+  variables:
+    # FIXME: work around to make slashes work in `cache:key`. see https://gitlab.com/gitlab-org/gitlab/-/issues/439898
+    __CACHE_KEY_HACK: "$[[ inputs.root_dir ]]"
+    TF_ROOT: $[[ inputs.root_dir ]]
+  image:
+    name: '$[[ inputs.image_registry_base ]]/$[[ inputs.image_name ]]:$[[ inputs.version ]]-opentofu$[[ inputs.opentofu_version ]]'
+  script:
+    - gitlab-tofu $[[ inputs.command ]]
+