diff --git a/.gitignore b/.gitignore
index db139e6f6c126bc7e74fd52da79f082037efaa11..0ec1622a8ad79f5fbbb22e3fc27796688bf5f992 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
 *.retry
 .ansible_vault_pw
 ansible.cfg
+.fact-cache
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..11cdd260e85a9ffa6a6e049c7e2a8e09f01d687d
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,35 @@
+
+stages:
+  - build
+  - check
+
+
+docker_ci_image:
+  variables:
+    IMAGE_TAG: $CI_REGISTRY_NAME:$CI_COMMIT_REF_SLUG
+  stage: build
+  image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:stable
+  services:
+    - name: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:dind
+      alias: docker
+  script:
+    - echo -n "$CI_JOB_TOKEN" | docker login -u gitlab-ci-token --password-stdin $CI_REGISTRY_NAME
+    - cd ci && docker build -build-arg ci_token=$CI_JOB_TOKEN --pull -t $IMAGE_TAG .
+    - docker tag $IMAGE_TAG $CI_REGISTRY_NAME:runner
+    - docker push $CI_REGISTRY_NAME:runner
+  only:
+    changes:
+      - ci/**
+
+check:
+  stage: check
+  image: registry.git.autistici.org/streampunk/ansible:runner
+  script:
+    - ln -s ci/ansible-ci.cfg ansible.cfg
+    - echo -n "${ANSIBLE_VAULT_PASSWORD}" > .ansible_vault_pw
+    - export ANSIBLE_VAULT_PASSWORD_FILE=.ansible_vault_pw
+    - with-ssh-key ansible-playbook -i hosts.ini --diff --check site.yml
+  artifacts:
+    paths:
+      - ansible.log
+
diff --git a/ci/Dockerfile b/ci/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..36b94538c8a8275471937a628959b2bc0838e1f0
--- /dev/null
+++ b/ci/Dockerfile
@@ -0,0 +1,9 @@
+FROM debian:bullseye
+COPY with-ssh-key /usr/bin/with-ssh-key
+RUN apt-get -q update \
+    && env DEBIAN_FRONTEND=noninteractive apt-get install -q -y --no-install-recommends \
+        ansible python3-pip \
+    && pip install mitogen \
+    && apt-get clean \
+    && rm -fr /var/lib/apt/lists/*
+
diff --git a/ci/ansible-ci.cfg b/ci/ansible-ci.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..1c53ae2dd3a02eec2dae96e435b8dbdb3721f696
--- /dev/null
+++ b/ci/ansible-ci.cfg
@@ -0,0 +1,15 @@
+
+[defaults]
+strategy = mitogen_linear
+display_skipped_hosts = False
+nocows = 1
+force_handlers = True
+log_path = ansible.log
+stdout_callback = actionable
+
+[ssh_connection]
+ssh_args = -C -o ControlMaster=auto -o ControlPersist=120s
+control_path_dir = ~/.ansible/cp
+control_path = %(directory)s/%%h-%%r
+pipelining = True
+scp_if_ssh = True
diff --git a/ci/with-ssh-key b/ci/with-ssh-key
new file mode 100755
index 0000000000000000000000000000000000000000..a8464953185c2206b2da78857152dc0159f37751
--- /dev/null
+++ b/ci/with-ssh-key
@@ -0,0 +1,29 @@
+#!/bin/sh
+#
+# Wrap a command with an ssh-agent with loaded credentials.
+# The (optional) private key is passed as the environment variable
+# SSH_PRIVATE_KEY.
+#
+
+key=${SSH_PRIVATE_KEY:-}
+if [ -z "$key" ]; then
+    "$@"
+    exit $?
+fi
+
+mkdir -p ~/.ssh
+chmod 0700 ~/.ssh
+echo "$key" > ~/.ssh/key
+chmod 0600 ~/.ssh/key
+eval `ssh-agent -s`
+trap "ssh-agent -k >/dev/null" EXIT
+
+ssh-add ~/.ssh/key
+if [ $? -gt 0 ]; then
+    echo "ERROR: could not load SSH_PRIVATE_KEY" >&2
+    exit 2
+fi
+
+"$@"
+exit $?
+