diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 1072abdc022d7956d571d4a887df9b78a655bbbf..69d24dd60690229f664e03241332bf8d48d7c278 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,15 +1,41 @@
 include: "https://git.autistici.org/pipelines/containers/raw/master/common.yml"
 
-# test the newly built container before releasing it.
+# Test the newly built container before releasing it.
+#
+# Use "float-podman-runner" to set up a rootless container like 'float' does.
+# Use "ai3/testdata" to populate a MySQL database that looks like the real
+# noblogs.org, and finally run some HTTP tests (in docker/test.sh).
+# NOTE: The database configuration (docker/test-config.json) does not match
+# production as there is no sharding, instead everything is stored in the
+# main 'noblogs' database.
 test:
   stage: container-test
   image: registry.git.autistici.org/pipelines/images/test/float-podman-runner:master
+  services:
+    - name: docker.io/library/memcached:alpine
+      alias: memcache
+    - name: docker.io/library/mysql:latest
+      alias: mysql
   tags: [podman]
   variables:
     APACHE_PORT: 8080
-    SITE_URL: "http://localhost:8080"
+    TARGET_URL: "https://noblogs.org"
+    TARGET_ADDR: "127.0.0.1:8443"
+    MYSQL_DATABASE: noblogs
+    MYSQL_ROOT_PASSWORD: rootpass
   before_script:
     - echo -n "$CI_JOB_TOKEN" | podman login -u gitlab-ci-token --password-stdin $CI_REGISTRY
   script:
-    - with-container --expose=8080 $IMAGE_TAG ./docker/test.sh
+    - dnf install -y mysql xz git
+    - git clone --depth=1 https://gitlab-ci-token:${CI_JOB_TOKEN}@git.autistici.org/ai3/testdata.git /tmp/testdata.$CI_JOB_ID
+    - xzcat /tmp/testdata.$CI_JOB_ID/noblogs/noblogs.sql.xz | mysql --user=root --password=rootpass --host=mysql noblogs
+    - for blog in cavallette detriti docs ; do xzcat /tmp/testdata.$CI_JOB_ID/noblogs/noblogs_${blog}.sql.xz | mysql --user=root --password=rootpass --host=mysql noblogs; done
+    - with-container --expose=8080 --mount=type=bind,source=docker/test-config.json,destination=/etc/noblogs/config.json --mount=type=tmpfs,destination=/opt/noblogs/www/wp-content/blogs.dir --mount=type=tmpfs,destination=/opt/noblogs/www/wp-content/cache $IMAGE_TAG ./docker/test.sh
+  artifacts:
+    paths:
+      - test-artifacts/screenshots/
+    expose_as: Screenshots
+    when: always
+    reports:
+      junit: test-artifacts/pytest.xml
 
diff --git a/composer.json b/composer.json
index deca577078709b76b50ee0fbc4ead3c2b670cf1f..5238f86b8b063bf9e63a1dbe558b92a94c5890b1 100644
--- a/composer.json
+++ b/composer.json
@@ -72,7 +72,7 @@
         "wpackagist-theme/wp-andreas01": "2.0",
         "npm-asset/scriptaculous-js": "1.9.0",
         "npm-asset/prototype-js-core": "1.7.3",
-        "noblogs/r2db": "0.1.8",
+        "noblogs/r2db": "0.1.9",
         "noblogs/ai-global-activity-plugin": "0.0.24",
         "noblogs/ai-mu-plugins": "0.4.15",
         "noblogs/noblogs-wp-ssl": "0.1.0",
diff --git a/composer.lock b/composer.lock
index 6a80cc4426647eaab51c18c48838b6255825fb3d..2e25957b3d3a8bd7110fc9cd3cd954c0d1d0b4e3 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "52e34f831ed12ba8c8a5c1b5499cd0e4",
+    "content-hash": "e9d92b02c9180669470be08ab8aacce8",
     "packages": [
         {
             "name": "composer/installers",
@@ -461,16 +461,16 @@
         },
         {
             "name": "noblogs/r2db",
-            "version": "0.1.8",
+            "version": "0.1.9",
             "source": {
                 "type": "git",
                 "url": "https://git.autistici.org/noblogs/r2db.git",
-                "reference": "e68d8a91c6f631665569ffbce920991e3d135874"
+                "reference": "ab2591f8b19c64d8483c15de233183bfe456d35a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://git.autistici.org/api/v4/projects/449/packages/composer/archives/noblogs/r2db.zip?sha=e68d8a91c6f631665569ffbce920991e3d135874",
-                "reference": "e68d8a91c6f631665569ffbce920991e3d135874",
+                "url": "https://git.autistici.org/api/v4/projects/449/packages/composer/archives/noblogs/r2db.zip?sha=ab2591f8b19c64d8483c15de233183bfe456d35a",
+                "reference": "ab2591f8b19c64d8483c15de233183bfe456d35a",
                 "shasum": ""
             },
             "require": {
@@ -2100,12 +2100,12 @@
     "packages-dev": [],
     "aliases": [],
     "minimum-stability": "stable",
-    "stability-flags": {},
+    "stability-flags": [],
     "prefer-stable": false,
     "prefer-lowest": false,
     "platform": {
         "php": ">=7.1"
     },
-    "platform-dev": {},
-    "plugin-api-version": "2.6.0"
+    "platform-dev": [],
+    "plugin-api-version": "2.3.0"
 }
diff --git a/docker/test-config.json b/docker/test-config.json
new file mode 100644
index 0000000000000000000000000000000000000000..ee9510447eb85416895af22c8b6ab6eabfa2d446
--- /dev/null
+++ b/docker/test-config.json
@@ -0,0 +1,36 @@
+{
+  "secrets": {
+    "auth_key": "be51a7fb3f4c3924c3a351a834ae605f",
+    "secure_auth_key": "be51a7fb3f4c3924c3a351a834ae605f",
+    "logged_in_key": "be51a7fb3f4c3924c3a351a834ae605f",
+    "nonce_key": "be51a7fb3f4c3924c3a351a834ae605f",
+    "auth_salt": "be51a7fb3f4c3924c3a351a834ae605f",
+    "secure_auth_salt": "be51a7fb3f4c3924c3a351a834ae605f",
+    "logged_in_salt": "be51a7fb3f4c3924c3a351a834ae605f",
+    "nonce_salt": "be51a7fb3f4c3924c3a351a834ae605f"
+  },
+  "db_config": {
+    "backends": {
+      "backend_1": {
+        "host": "mysql",
+        "port": 3306,
+        "user": "root",
+        "password": "rootpass",
+        "name": "noblogs"
+      }
+    },
+    "is_master": true,
+    "master": {
+      "local_host": "mysql",
+      "host": "mysql",
+      "port": 3306,
+      "user": "root",
+      "password": "rootpass",
+      "name": "noblogs"
+    }
+  },
+  "memcached": ["memcache:11211"],
+  "debug": false,
+  "debug_cookie_name": "__unused__",
+  "local_backend_name": "backend_1"
+}
diff --git a/docker/test.sh b/docker/test.sh
index 55dcaed17821b2de2f784e2fe7dffd44b2b07191..426151e7ed0031877bcee0b41703a88ca4a672b9 100755
--- a/docker/test.sh
+++ b/docker/test.sh
@@ -1,4 +1,28 @@
 #!/bin/sh
 
-exec curl -H 'Host: noblogs.org' -v -s ${SITE_URL}/
+TESTSUITE_IMAGE="registry.git.autistici.org/noblogs/testsuite:main"
+
+# Run a ssl-reverse-proxy in the background.
+podman run -d --expose 8443 --rm --env PROXY_DOMAIN=noblogs.org --env PROXY_BACKEND_ADDR=localhost:8080 --network=host registry.git.autistici.org/pipelines/images/test/ssl-reverse-proxy:main
+
+# Run the test suite using Podman, in the foreground.
+# Mount a local temporary directory to hold artifacts.
+mkdir -p test-artifacts
+podman run --rm --pull=always \
+    --env TARGET_URL=${TARGET_URL} \
+    --env TARGET_ADDR=${TARGET_ADDR} \
+    --env SCREENSHOT_DIR=/artifacts/screenshots \
+    --network=host \
+    --mount type=bind,source=test-artifacts,destination=/artifacts \
+    ${TESTSUITE_IMAGE} \
+    --junitxml=/artifacts/pytest.xml
+rc=$?
+
+# Convert into permanent error.
+if [ $rc -gt 0 ]; then
+    echo "test suite exited with status $rc" >&2
+    exit 2
+fi
+
+exit 0