diff --git a/README.md b/README.md
index 8f19c5f..e0c4626 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ Need to deploy your Worker to Cloudflare? Python Workers are in open beta and ha
- [**`image-redraw/`**](image-redraw) — an example that combines [FastAPI](https://fastapi.tiangolo.com/), [R2](https://developers.cloudflare.com/r2/), [Queues](https://developers.cloudflare.com/queues/), [Workflows](https://developers.cloudflare.com/workflows/) and [Workers AI](https://developers.cloudflare.com/workers-ai/) to redraw uploaded images.
- [**`django/`**](django) — runs a naive Django WSGI application directly on Python Workers.
- [**`django-todo-d1/`**](django-todo-d1) — uses Django with D1 for a basic TODO application.
+- [**`django-markdown-r2/`**](django-markdown-r2) — a server-rendered Django blog using Durable Object SQLite and R2 for media storage.
- [**`fastapi-todo/`**](fastapi-todo) — implements the [Todo-Backend](https://todobackend.com) spec with FastAPI (ASGI) and D1.
- [**`flask-todo/`**](flask-todo) — implements the same [Todo-Backend](https://todobackend.com) API with [Flask](https://flask.palletsprojects.com/) (WSGI) and D1.
diff --git a/django-markdown-r2/README.md b/django-markdown-r2/README.md
new file mode 100644
index 0000000..d115a8c
--- /dev/null
+++ b/django-markdown-r2/README.md
@@ -0,0 +1,24 @@
+# Django Markdown Blog + Durable Objects + R2
+
+[](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/python-workers-examples/tree/main/django-markdown-r2)
+
+A blog built with Django on Cloudflare Python Workers, using Durable Objects for database storage and R2 for image storage.
+
+## Local setup
+
+Install [uv](https://docs.astral.sh/uv/getting-started/installation/#standalone-installer), then install the project dependencies and start the Worker:
+
+```sh
+uv sync
+uv run pywrangler dev
+```
+
+Open http://localhost:8787/. Wrangler provisions the configured Durable Object locally and simulates the `IMAGES` R2 binding.
+
+## Remote setup and deployment
+
+Create an R2 bucket, update the `IMAGES` bucket name in `wrangler.jsonc`, and deploy:
+
+```sh
+uv run pywrangler deploy
+```
diff --git a/django-markdown-r2/package.json b/django-markdown-r2/package.json
new file mode 100644
index 0000000..0b530ce
--- /dev/null
+++ b/django-markdown-r2/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "django-markdown-r2-worker",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "deploy": "uv run pywrangler deploy",
+ "dev": "uv run pywrangler dev",
+ "start": "uv run pywrangler dev"
+ },
+ "devDependencies": {
+ "wrangler": "^4.114.0"
+ }
+}
diff --git a/django-markdown-r2/pyproject.toml b/django-markdown-r2/pyproject.toml
new file mode 100644
index 0000000..04abac5
--- /dev/null
+++ b/django-markdown-r2/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "django-markdown-r2-worker"
+version = "0.1.0"
+description = "Server-rendered Django blog backed by Durable Object SQLite and R2"
+readme = "README.md"
+requires-python = ">=3.13"
+dependencies = [
+ "django",
+ "django-cf>=0.2.16",
+ "markdown-it-py==4.2.0",
+]
+
+[dependency-groups]
+dev = [
+ "workers-py",
+ "workers-runtime-sdk",
+]
diff --git a/django-markdown-r2/src/articles/__init__.py b/django-markdown-r2/src/articles/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django-markdown-r2/src/articles/apps.py b/django-markdown-r2/src/articles/apps.py
new file mode 100644
index 0000000..9baf7c9
--- /dev/null
+++ b/django-markdown-r2/src/articles/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ArticlesConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "articles"
diff --git a/django-markdown-r2/src/articles/forms.py b/django-markdown-r2/src/articles/forms.py
new file mode 100644
index 0000000..67e28cf
--- /dev/null
+++ b/django-markdown-r2/src/articles/forms.py
@@ -0,0 +1,21 @@
+from django import forms
+
+from .models import Article
+
+
+class ArticleForm(forms.ModelForm):
+ class Meta:
+ model = Article
+ fields = ["title", "body", "image"]
+ widgets = {
+ "body": forms.Textarea(attrs={"rows": 16}),
+ "image": forms.ClearableFileInput(
+ attrs={"accept": "image/gif,image/jpeg,image/png,image/webp"}
+ ),
+ }
+ help_texts = {"image": "PNG, JPEG, GIF, or WebP."}
+
+
+class ArticleEditForm(ArticleForm):
+ class Meta(ArticleForm.Meta):
+ fields = ["title", "body"]
diff --git a/django-markdown-r2/src/articles/models.py b/django-markdown-r2/src/articles/models.py
new file mode 100644
index 0000000..52fb6cb
--- /dev/null
+++ b/django-markdown-r2/src/articles/models.py
@@ -0,0 +1,39 @@
+import uuid
+
+from django.core.validators import FileExtensionValidator
+from django.db import models
+from django.utils.text import slugify
+
+
+def generate_article_id() -> str:
+ return str(uuid.uuid4())
+
+
+class Article(models.Model):
+ id = models.CharField(primary_key=True, max_length=36, default=generate_article_id)
+ title = models.CharField(max_length=200)
+ slug = models.SlugField(max_length=100, unique=True)
+ body = models.TextField(max_length=20_000)
+ image = models.FileField(
+ upload_to="articles",
+ blank=True,
+ validators=[FileExtensionValidator(["gif", "jpeg", "jpg", "png", "webp"])],
+ )
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ db_table = "articles"
+ ordering = ["-created_at"]
+
+ def save(self, *args, **kwargs):
+ if not self.slug:
+ base = (slugify(self.title) or "article")[:100].rstrip("-")
+ candidate = base
+ suffix_number = 2
+ while type(self).objects.filter(slug=candidate).exists():
+ suffix = f"-{suffix_number}"
+ candidate = f"{base[: 100 - len(suffix)].rstrip('-')}{suffix}"
+ suffix_number += 1
+ self.slug = candidate
+ return super().save(*args, **kwargs)
diff --git a/django-markdown-r2/src/articles/templates/articles/article_detail.html b/django-markdown-r2/src/articles/templates/articles/article_detail.html
new file mode 100644
index 0000000..9f6f76e
--- /dev/null
+++ b/django-markdown-r2/src/articles/templates/articles/article_detail.html
@@ -0,0 +1,33 @@
+{% extends "articles/base.html" %}
+
+{% block title %}{{ article.title }} | Knowledge base{% endblock %}
+
+{% block content %}
+
+
+ {% if article.image %}
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/django-markdown-r2/src/articles/templates/articles/article_form.html b/django-markdown-r2/src/articles/templates/articles/article_form.html
new file mode 100644
index 0000000..7af1278
--- /dev/null
+++ b/django-markdown-r2/src/articles/templates/articles/article_form.html
@@ -0,0 +1,44 @@
+{% extends "articles/base.html" %}
+
+{% block title %}{% if is_edit %}Edit {{ article.title }}{% else %}New article{% endif %} | Knowledge base{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/django-markdown-r2/src/articles/templates/articles/article_list.html b/django-markdown-r2/src/articles/templates/articles/article_list.html
new file mode 100644
index 0000000..f4557d4
--- /dev/null
+++ b/django-markdown-r2/src/articles/templates/articles/article_list.html
@@ -0,0 +1,35 @@
+{% extends "articles/base.html" %}
+
+{% block title %}Articles | Knowledge base{% endblock %}
+
+{% block content %}
+
+
+ {% if articles %}
+ {% for article in articles %}
+
+
+
+ {{ article.rendered_body|truncatewords_html:45 }}
+
+
+
+ {% endfor %}
+ {% endif %}
+
+{% endblock %}
diff --git a/django-markdown-r2/src/articles/templates/articles/base.html b/django-markdown-r2/src/articles/templates/articles/base.html
new file mode 100644
index 0000000..bb20123
--- /dev/null
+++ b/django-markdown-r2/src/articles/templates/articles/base.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+ {% block title %}Knowledge base{% endblock %}
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+
diff --git a/django-markdown-r2/src/articles/views.py b/django-markdown-r2/src/articles/views.py
new file mode 100644
index 0000000..037a7e8
--- /dev/null
+++ b/django-markdown-r2/src/articles/views.py
@@ -0,0 +1,87 @@
+from datetime import datetime
+from pathlib import PurePosixPath
+
+from django.core.files.storage import default_storage
+from django.http import Http404, HttpResponse
+from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.safestring import SafeString, mark_safe
+from markdown_it import MarkdownIt
+
+from .forms import ArticleEditForm, ArticleForm
+from .models import Article
+
+IMAGE_CONTENT_TYPES: dict[str, str] = {
+ ".gif": "image/gif",
+ ".jpeg": "image/jpeg",
+ ".jpg": "image/jpeg",
+ ".png": "image/png",
+ ".webp": "image/webp",
+}
+
+
+def render_markdown(markdown: str) -> SafeString:
+ return mark_safe(MarkdownIt("js-default").render(markdown))
+
+
+def format_date(value: datetime) -> str:
+ return f"{value:%B} {value.day}, {value.year}"
+
+
+def article_list(request):
+ articles = list(Article.objects.all())
+ for article in articles:
+ article.rendered_body = render_markdown(article.body)
+ article.updated_date = format_date(article.updated_at)
+ article.updated_iso = article.updated_at.isoformat()
+ return render(request, "articles/article_list.html", {"articles": articles})
+
+
+def article_detail(request, slug):
+ article = get_object_or_404(Article, slug=slug)
+ return render(
+ request,
+ "articles/article_detail.html",
+ {
+ "article": article,
+ "created_date": format_date(article.created_at),
+ "created_iso": article.created_at.isoformat(),
+ "rendered_body": render_markdown(article.body),
+ "updated_date": format_date(article.updated_at),
+ "updated_iso": article.updated_at.isoformat(),
+ },
+ )
+
+
+def article_create(request):
+ form = ArticleForm(request.POST or None, request.FILES or None)
+ if request.method == "POST" and form.is_valid():
+ article = form.save()
+ return redirect("article-detail", slug=article.slug)
+ return render(
+ request, "articles/article_form.html", {"form": form, "is_edit": False}
+ )
+
+
+def article_edit(request, slug):
+ article = get_object_or_404(Article, slug=slug)
+ form = ArticleEditForm(request.POST or None, instance=article)
+ if request.method == "POST" and form.is_valid():
+ article = form.save()
+ return redirect("article-detail", slug=article.slug)
+ return render(
+ request,
+ "articles/article_form.html",
+ {"article": article, "form": form, "is_edit": True},
+ )
+
+
+def media_image(_request, name: str) -> HttpResponse:
+ path = PurePosixPath(name)
+ content_type = IMAGE_CONTENT_TYPES.get(path.suffix.lower())
+ if content_type is None or not default_storage.exists(name):
+ raise Http404
+ with default_storage.open(name, "rb") as image_file:
+ response = HttpResponse(image_file.read(), content_type=content_type)
+ response["Content-Disposition"] = "inline"
+ response["X-Content-Type-Options"] = "nosniff"
+ return response
diff --git a/django-markdown-r2/src/entry.py b/django-markdown-r2/src/entry.py
new file mode 100644
index 0000000..448ef1d
--- /dev/null
+++ b/django-markdown-r2/src/entry.py
@@ -0,0 +1,40 @@
+import os
+
+from django_cf import DjangoCFDurableObject
+from workers import DurableObject, Request, Response, WorkerEntrypoint
+
+KNOWLEDGE_BASE_NAME: str = "blog"
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "markdown_project.settings")
+from markdown_project.wsgi import application
+
+
+class KnowledgeBase(DjangoCFDurableObject, DurableObject):
+ def __init__(self, ctx, env):
+ super().__init__(ctx, env)
+ self.ctx.storage.sql.exec(
+ """
+ CREATE TABLE IF NOT EXISTS articles (
+ id TEXT PRIMARY KEY,
+ title VARCHAR(200) NOT NULL,
+ slug VARCHAR(100) NOT NULL UNIQUE,
+ body TEXT NOT NULL,
+ image VARCHAR(100) NOT NULL DEFAULT '',
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+ )
+ """
+ )
+ self.ctx.storage.sql.exec(
+ "CREATE INDEX IF NOT EXISTS articles_created_at_idx ON articles (created_at DESC)"
+ )
+
+ def get_app(self):
+ return application
+
+
+class Default(WorkerEntrypoint):
+ async def fetch(self, request: Request) -> Response:
+ do_id = self.env.DO_STORAGE.idFromName(KNOWLEDGE_BASE_NAME)
+ stub = self.env.DO_STORAGE.get(do_id)
+ return await stub.fetch(request)
diff --git a/django-markdown-r2/src/markdown_project/__init__.py b/django-markdown-r2/src/markdown_project/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django-markdown-r2/src/markdown_project/settings.py b/django-markdown-r2/src/markdown_project/settings.py
new file mode 100644
index 0000000..9a9a940
--- /dev/null
+++ b/django-markdown-r2/src/markdown_project/settings.py
@@ -0,0 +1,40 @@
+SECRET_KEY = "django-insecure-development-placeholder"
+DEBUG = False
+ALLOWED_HOSTS = ["*"]
+ROOT_URLCONF = "markdown_project.urls"
+WSGI_APPLICATION = "markdown_project.wsgi.application"
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+INSTALLED_APPS = ["articles"]
+MIDDLEWARE = [
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+]
+DATABASES = {
+ "default": {
+ "ENGINE": "django_cf.db.backends.do",
+ }
+}
+STORAGES = {
+ "default": {
+ "BACKEND": "django_cf.storage.R2Storage",
+ "OPTIONS": {
+ "binding": "IMAGES",
+ "location": "images",
+ "allow_overwrite": False,
+ },
+ }
+}
+MEDIA_URL = "/media/"
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.request",
+ ],
+ },
+ }
+]
+TIME_ZONE = "UTC"
+USE_TZ = False
diff --git a/django-markdown-r2/src/markdown_project/urls.py b/django-markdown-r2/src/markdown_project/urls.py
new file mode 100644
index 0000000..98e1dc4
--- /dev/null
+++ b/django-markdown-r2/src/markdown_project/urls.py
@@ -0,0 +1,10 @@
+from articles import views
+from django.urls import path
+
+urlpatterns = [
+ path("", views.article_list, name="article-list"),
+ path("articles/new/", views.article_create, name="article-create"),
+ path("articles//", views.article_detail, name="article-detail"),
+ path("articles//edit/", views.article_edit, name="article-edit"),
+ path("media/images/", views.media_image, name="media-image"),
+]
diff --git a/django-markdown-r2/src/markdown_project/wsgi.py b/django-markdown-r2/src/markdown_project/wsgi.py
new file mode 100644
index 0000000..e15c994
--- /dev/null
+++ b/django-markdown-r2/src/markdown_project/wsgi.py
@@ -0,0 +1,7 @@
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "markdown_project.settings")
+
+application = get_wsgi_application()
diff --git a/django-markdown-r2/wrangler.jsonc b/django-markdown-r2/wrangler.jsonc
new file mode 100644
index 0000000..e1e46f4
--- /dev/null
+++ b/django-markdown-r2/wrangler.jsonc
@@ -0,0 +1,32 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "django-markdown-r2-worker",
+ "main": "src/entry.py",
+ "compatibility_date": "2026-08-21",
+ "compatibility_flags": [
+ "python_workers"
+ ],
+ "durable_objects": {
+ "bindings": [
+ {
+ "name": "DO_STORAGE",
+ "class_name": "KnowledgeBase"
+ }
+ ]
+ },
+ "migrations": [
+ {
+ "tag": "v1",
+ "new_sqlite_classes": ["KnowledgeBase"]
+ }
+ ],
+ "r2_buckets": [
+ {
+ "binding": "IMAGES",
+ "bucket_name": "django-markdown-r2-images"
+ }
+ ],
+ "observability": {
+ "enabled": true
+ }
+}
diff --git a/tests/test_examples.py b/tests/test_examples.py
index 2d6e80f..52cc136 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -1,4 +1,6 @@
+import re
import subprocess
+import uuid
import pytest
import requests
@@ -386,3 +388,27 @@ def init_django_todo_d1_db():
def test_django_todo_d1(init_django_todo_d1_db, dev_server):
assert_todo_backend(dev_server)
+
+
+def csrf_token(session, base_url, path):
+ response = session.get(f"{base_url}{path}")
+ assert response.status_code == 200
+ match = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', response.text)
+ assert match is not None
+ return match.group(1)
+
+
+def test_django_markdown_r2(dev_server):
+ base_url = f"http://localhost:{dev_server}"
+ session = requests.Session()
+ article_id = uuid.uuid4().hex
+ title = f"Safe Markdown {article_id}"
+ slug = f"safe-markdown-{article_id}"
+
+ response = session.get(base_url)
+ assert response.status_code == 200
+ assert 'Articles
' in response.text
+ assert (
+ session.get(f"{base_url}/articles/missing-{uuid.uuid4().hex}/").status_code
+ == 404
+ )