diff --git a/README.rst b/README.rst
index 3a25de540491bcaff07888f4a2671912d554dbec..6b1d3fd0fbce32a9403b8be932f122514e2ede3c 100644
--- a/README.rst
+++ b/README.rst
@@ -66,7 +66,13 @@ configuration:
 Do replace ``SECRET_KEY`` with a unique, random value; and customise
 ``SQLALCHEMY_DATABASE_URI`` according to your environment.  See `SQLAlchemy
 documentation`_ for details.  You may need to create a database in your database
-server beforehand.  Unfortunately, SQLite support is broken at the moment.
+server beforehand.
+
+Run database migrations:
+
+.. code:: shell-session
+
+	% pdm run migrate
 
 To start the development server:
 
diff --git a/alembic.ini b/alembic.ini
new file mode 100644
index 0000000000000000000000000000000000000000..358051bb3468d4340e3517f5a1c54f2d0ede1829
--- /dev/null
+++ b/alembic.ini
@@ -0,0 +1,47 @@
+[alembic]
+script_location = schilder2000/migrations
+
+[post_write_hooks]
+hooks = ruff ruff_format
+
+ruff.type = exec
+ruff.executable = ruff
+ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+ruff_format.type = exec
+ruff_format.executable = ruff
+ruff_format.options = format REVISION_SCRIPT_FILENAME
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/pdm.lock b/pdm.lock
index 2381896c79415f6ad85b5531faf6084472253447..4e10e7c2f4520161b87e8efad7608ff0bbf9cd17 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -5,7 +5,7 @@
 groups = ["default", "auth-ldap", "auth-oauth", "auth-saml", "db-mysql", "db-postgres", "dev"]
 strategy = ["inherit_metadata"]
 lock_version = "4.5.0"
-content_hash = "sha256:1af461aea0170421afe47c809cb315739413db60f16b0d60a7d38d91ddf457ca"
+content_hash = "sha256:0fe1b318475e65a03e58297779bfe8676d845960ce808dc040660179751c3dd3"
 
 [[metadata.targets]]
 requires_python = ">=3.12"
@@ -84,6 +84,24 @@ files = [
     {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"},
 ]
 
+[[package]]
+name = "alembic"
+version = "1.13.2"
+requires_python = ">=3.8"
+summary = "A database migration tool for SQLAlchemy."
+groups = ["default"]
+dependencies = [
+    "Mako",
+    "SQLAlchemy>=1.3.0",
+    "importlib-metadata; python_version < \"3.9\"",
+    "importlib-resources; python_version < \"3.9\"",
+    "typing-extensions>=4",
+]
+files = [
+    {file = "alembic-1.13.2-py3-none-any.whl", hash = "sha256:6b8733129a6224a9a711e17c99b08462dbf7cc9670ba8f2e2ae9af860ceb1953"},
+    {file = "alembic-1.13.2.tar.gz", hash = "sha256:1ff0ae32975f4fd96028c39ed9bb3c867fe3af956bd7bb37343b54c9fe7445ef"},
+]
+
 [[package]]
 name = "arrow"
 version = "1.3.0"
@@ -856,6 +874,20 @@ files = [
     {file = "lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f"},
 ]
 
+[[package]]
+name = "mako"
+version = "1.3.5"
+requires_python = ">=3.8"
+summary = "A super-fast templating language that borrows the best ideas from the existing templating languages."
+groups = ["default"]
+dependencies = [
+    "MarkupSafe>=0.9.2",
+]
+files = [
+    {file = "Mako-1.3.5-py3-none-any.whl", hash = "sha256:260f1dbc3a519453a9c856dedfe4beb4e50bd5a26d96386cb6c80856556bb91a"},
+    {file = "Mako-1.3.5.tar.gz", hash = "sha256:48dbc20568c1d276a2698b36d968fa76161bf127194907ea6fc594fa81f943bc"},
+]
+
 [[package]]
 name = "markdown-it-py"
 version = "3.0.0"
diff --git a/pyproject.toml b/pyproject.toml
index 2e3f528adc2a007764cf408b4f722a4448b87929..1f8f77ec18ee8f5d18fb8630e975ea97cd10f6c8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,6 +20,7 @@ dependencies = [
     "python-webpack-boilerplate~=1.0",
     "pyipp @ git+https://github.com/ctalkington/python-ipp",
     "Flask-Multipass~=0.5",
+    "alembic~=1.13",
 ]
 
 [project.optional-dependencies]
@@ -50,6 +51,7 @@ dev = [
 
 [tool.pdm.scripts]
 serve = "flask -A schilder2000 run --debug"
+migrate = "flask -A schilder2000 alembic upgrade head"
 
 [tool.pdm.build]
 includes = [
diff --git a/schilder2000/__init__.py b/schilder2000/__init__.py
index a4c9fa3c280fa85c31ba2ee7b03563c4a1572290..41757ddc8b48e1da6c1735006125dd64a4be41d7 100644
--- a/schilder2000/__init__.py
+++ b/schilder2000/__init__.py
@@ -5,6 +5,7 @@ from flask_sqlalchemy import SQLAlchemy
 from flask_wtf.csrf import CSRFProtect
 from webpack_boilerplate.config import setup_jinja2_ext
 
+from . import cli
 from .helpers import Flask, identity_handler, require_login
 
 
@@ -17,14 +18,13 @@ def create_app():
     app = Flask(__name__, instance_relative_config=True)
     app.config.from_pyfile("config.py")
 
+    app.cli.add_command(cli.alembic)
+
     db.init_app(app)
     # Ignore linter error unused export: we need to import the models to
     # register them with SQLAlchemy
     from . import models  # noqa: F401
 
-    with app.app_context():
-        db.create_all()
-
     csrf.init_app(app)
 
     if app.config["REQUIRE_LOGIN"]:
diff --git a/schilder2000/cli.py b/schilder2000/cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..46199455a881de660a4d5fbdb4572d7efe516490
--- /dev/null
+++ b/schilder2000/cli.py
@@ -0,0 +1,18 @@
+import sys
+from os.path import basename
+
+import click
+from flask.cli import with_appcontext
+from alembic import config
+
+
+@click.command(
+    context_settings={"ignore_unknown_options": True, "help_option_names": []}
+)
+@click.argument("argv", nargs=-1)
+@with_appcontext
+def alembic(argv):
+    """Wrap the alembic CLI tool."""
+    prog = " ".join([basename(sys.argv[0]), sys.argv[1]])
+    cli = config.CommandLine(prog=prog)
+    cli.main(argv=argv)
diff --git a/schilder2000/migrations/__init__.py b/schilder2000/migrations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/schilder2000/migrations/env.py b/schilder2000/migrations/env.py
new file mode 100644
index 0000000000000000000000000000000000000000..003ad7767140bf798e00614b20d292f9fed9b2fa
--- /dev/null
+++ b/schilder2000/migrations/env.py
@@ -0,0 +1,83 @@
+from logging.config import fileConfig
+
+from alembic import context
+
+from flask import current_app as app
+
+try:
+    app.app_context()
+except RuntimeError:
+    import schilder2000
+
+    app = schilder2000.create_app()
+
+
+db = app.extensions["sqlalchemy"]
+
+# Provide access to the values within alembic.ini.
+config = context.config
+
+# Sets up Python logging.
+fileConfig(config.config_file_name)
+
+# Sets up metadata for autogenerate support,
+target_metadata = db.metadata
+
+
+def run_migrations_offline():
+    """
+    Run migrations in 'offline' mode.
+
+    This configures the context with just a URL and not an Engine, though an
+    Engine is acceptable here as well. By skipping the Engine creation we
+    don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the script output.
+    """
+    url = app.config["SQLALCHEMY_DATABASE_URI"]
+    context.configure(
+        url=url,
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online():
+    """
+    Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine and associate a connection
+    with the context.
+    """
+
+    # If you use Alembic revision's --autogenerate flag this function will
+    # prevent Alembic from creating an empty migration file if nothing changed.
+    # Source: https://alembic.sqlalchemy.org/en/latest/cookbook.html
+    def process_revision_directives(context, revision, directives):
+        if config.cmd_opts.autogenerate:
+            script = directives[0]
+            if script.upgrade_ops.is_empty():
+                directives[:] = []
+
+    with app.app_context():
+        connectable = db.engine
+
+        with connectable.connect() as connection:
+            context.configure(
+                connection=connection,
+                target_metadata=target_metadata,
+                process_revision_directives=process_revision_directives,
+            )
+
+            with context.begin_transaction():
+                context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()
diff --git a/schilder2000/migrations/script.py.mako b/schilder2000/migrations/script.py.mako
new file mode 100644
index 0000000000000000000000000000000000000000..2c0156303a8df3ffdc9de87765bf801bf6bea4a5
--- /dev/null
+++ b/schilder2000/migrations/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+    ${downgrades if downgrades else "pass"}
diff --git a/schilder2000/migrations/versions/7a38fc23dda5_initial_revision.py b/schilder2000/migrations/versions/7a38fc23dda5_initial_revision.py
new file mode 100644
index 0000000000000000000000000000000000000000..e1808a506117168707885c48f7bd5b3338799cbb
--- /dev/null
+++ b/schilder2000/migrations/versions/7a38fc23dda5_initial_revision.py
@@ -0,0 +1,33 @@
+"""Initial revision
+
+Revision ID: 7a38fc23dda5
+Revises:
+Create Date: 2024-09-16 15:37:03.537826
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "7a38fc23dda5"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.create_table(
+        "schild",
+        sa.Column("ident", sa.Uuid(), nullable=False),
+        sa.Column("title", sa.String(length=31), nullable=False),
+        sa.Column("text", sa.Text(), nullable=False),
+        sa.Column("image", sa.String(length=255), nullable=True),
+        sa.Column("template", sa.String(length=255), nullable=False),
+        sa.PrimaryKeyConstraint("ident"),
+    )
+
+
+def downgrade():
+    op.drop_table("schild")