diff --git a/.dockerignore b/.dockerignore
index b9ab463..3b82016 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -12,3 +12,10 @@ Dockerfile
 Dockerfile.web
 docker-compose.yml
 dockersetup.py
+*.pem
+*.log
+*.patch
+*.zst
+.venv
+.idea
+.DS_Store
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..74405ac
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,22 @@
+# SPDX-License-Identifier: MIT
+
+name: CI
+
+on:
+  push:
+    branches: [master,master-next]
+  pull_request:
+    branches: [master]
+
+permissions:
+  contents: read
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    timeout-minutes: 30
+    steps:
+      - uses: actions/checkout@v5
+
+      - name: Build image and run tests
+        run: docker/run-tests.sh
diff --git a/.github/workflows/safety.yml b/.github/workflows/safety.yml
new file mode 100644
index 0000000..4840a08
--- /dev/null
+++ b/.github/workflows/safety.yml
@@ -0,0 +1,40 @@
+# SPDX-License-Identifier: MIT
+
+name: Safety
+
+on:
+  push:
+    branches: [master,master-next]
+  schedule:
+    - cron: '0 6 * * 1'
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+jobs:
+  scan:
+    runs-on: ubuntu-latest
+    timeout-minutes: 15
+    env:
+      SAFETY_API_KEY: ${{ secrets.SAFETY_API_KEY }}
+    # Steps are skipped where the secret isn't set, e.g. other forks.
+    steps:
+      - uses: actions/checkout@v5
+
+      - name: Build image
+        if: env.SAFETY_API_KEY != ''
+        run: docker build -t layerindex-test .
+
+      # Scans the versions actually installed in the image, so ranges in
+      # requirements.txt (e.g. Django) can't hide vulnerable releases.
+      - name: Safety scan
+        if: env.SAFETY_API_KEY != ''
+        run: |
+          mkdir safety-scan
+          cp .safety-project.ini safety-scan/
+          docker run --rm layerindex-test pip3 freeze > safety-scan/requirements.txt
+          # typer pinned: newer releases break safety 3.7.0 ("no active click context")
+          python3 -m venv /tmp/safety
+          /tmp/safety/bin/pip install -q safety==3.7.0 typer==0.23.0
+          /tmp/safety/bin/safety scan --target safety-scan --detailed-output
diff --git a/.safety-project.ini b/.safety-project.ini
new file mode 100644
index 0000000..e7ae822
--- /dev/null
+++ b/.safety-project.ini
@@ -0,0 +1,5 @@
+[project]
+id = layerindex-web
+url = /codebases/layerindex-web/findings
+name = layerindex-web
+
diff --git a/docker/run-tests.sh b/docker/run-tests.sh
new file mode 100755
index 0000000..e655611
--- /dev/null
+++ b/docker/run-tests.sh
@@ -0,0 +1,41 @@
+#!/bin/sh
+# layerindex-web - run the test suite in the production image against a
+# throwaway MariaDB. Used by CI; can also be run locally from the repo root.
+#
+# SPDX-License-Identifier: MIT
+
+set -e
+
+IMAGE=${IMAGE:-layerindex-test}
+NET=layerindex-test
+DB=layerindex-test-db
+
+docker build -t "$IMAGE" .
+
+docker network create $NET >/dev/null
+trap 'docker rm -f $DB >/dev/null; docker network rm $NET >/dev/null' EXIT
+
+# Alias "layersdb" matches the default DATABASE_HOST in docker/settings.py;
+# testingpw is that file's default password, not a real secret.
+docker run -d --name $DB --network $NET --network-alias layersdb \
+    -e MARIADB_ROOT_PASSWORD=testingpw mariadb:10.11 \
+    --character-set-server=utf8mb4 >/dev/null
+until docker exec $DB healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; do
+    sleep 1
+done
+
+# Runs as root only to prepare the container: tests/test_update.py rewrites
+# settings.py and bitbake writes its lock file next to it, the update scripts
+# expect bitbake already fetched, and the tests commit to git and call tree.
+docker run --rm --network $NET -u root \
+    -e SECRET_KEY=ci-only \
+    -e GIT_AUTHOR_NAME=ci -e GIT_AUTHOR_EMAIL=ci@example.com \
+    -e GIT_COMMITTER_NAME=ci -e GIT_COMMITTER_EMAIL=ci@example.com \
+    -v "$PWD/tests:/src/tests:ro" -w /opt/layerindex "$IMAGE" sh -ec '
+        apt-get update -qq && apt-get install -y -qq tree >/dev/null
+        cp -r /src/tests /opt/layerindex/
+        chown -R layers /opt/layerindex
+        su layers -s /bin/sh -c "set -e
+            git clone -q https://git.openembedded.org/bitbake /opt/workdir/https___git_openembedded_org_bitbake
+            pip3 install -q --user --no-warn-script-location pytest==9.0.3 pytest-django==4.12.0
+            python3 -m pytest -rfE -p no:cacheprovider tests/"'
