diff --git a/README.md b/README.md
index 3893d07..838395e 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,26 @@ DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.1.x:8000 uv run python manage.py runs
Sessions last a year, so your phone stays logged in.
+## Static files
+
+WhiteNoise serves `/static/` from Django itself, so there is no nginx or S3 to
+set up. `collectstatic` hashes every file's contents into its name and writes
+gzip/brotli copies alongside, and WhiteNoise serves those with a ten-year
+immutable cache header — worth having on a phone over a slow connection.
+
+```bash
+uv run python manage.py collectstatic --noinput
+```
+
+Run that whenever an icon or CSS file changes. In `DEBUG=1` Django serves the
+files directly and you can skip it; with `DEBUG=0` the hashed manifest must
+exist or pages referencing `{% static %}` will fail.
+
+`manifest.webmanifest` and `sw.js` are templates rather than static files: the
+manifest so `{% static %}` resolves the icons to their hashed names (the
+staticfiles storage only rewrites URLs inside CSS and JS), and the worker
+because it has to be served from `/` to control the `/admin/` pages it caches.
+
## Installing it on Android (PWA)
The Today page is a progressive web app: Chrome will offer *Add to home screen*
diff --git a/osiris/settings.py b/osiris/settings.py
index 7cdddc4..7813507 100644
--- a/osiris/settings.py
+++ b/osiris/settings.py
@@ -54,6 +54,9 @@ INSTALLED_APPS = [
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
+ # Directly below SecurityMiddleware and above everything else, per WhiteNoise's
+ # docs: it serves /static/ itself so no nginx/S3 is needed in front of Django.
+ "whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@@ -132,6 +135,15 @@ STATIC_ROOT = BASE_DIR / "static"
# PWA icons and manifest live here; STATIC_ROOT is only the collectstatic output.
STATICFILES_DIRS = [BASE_DIR / "assets"]
+STORAGES = {
+ "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
+ "staticfiles": {
+ # Hashes each file's contents into its name and gzip/brotli-compresses it,
+ # so WhiteNoise can serve it with a far-future cache header.
+ "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
+ },
+}
+
# Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
diff --git a/osiris/urls.py b/osiris/urls.py
index 7ab8d99..d8f6555 100644
--- a/osiris/urls.py
+++ b/osiris/urls.py
@@ -20,6 +20,16 @@ urlpatterns = [
),
name="service-worker",
),
+ # A template, not a static file, so {% static %} resolves the icons to their
+ # hashed names — .webmanifest is not URL-rewritten by the staticfiles storage.
+ path(
+ "manifest.webmanifest",
+ TemplateView.as_view(
+ template_name="manifest.webmanifest",
+ content_type="application/manifest+json",
+ ),
+ name="manifest",
+ ),
# Sits under /admin/ so it inherits the admin's styling and login, but must
# come before the admin's own catch-all patterns.
path("admin/", include("tracker.urls")),
diff --git a/pyproject.toml b/pyproject.toml
index 81fd5d8..821fc90 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,6 +7,7 @@ requires-python = ">=3.14"
dependencies = [
"django>=5.2",
"django-ninja>=1.4",
+ "whitenoise>=6.12.0",
]
[dependency-groups]
diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html
index aaec837..485b7b8 100644
--- a/templates/admin/base_site.html
+++ b/templates/admin/base_site.html
@@ -5,7 +5,7 @@
{% block extrahead %}
{{ block.super }}
-
+
diff --git a/assets/manifest.webmanifest b/templates/manifest.webmanifest
similarity index 76%
rename from assets/manifest.webmanifest
rename to templates/manifest.webmanifest
index e87304e..0923e54 100644
--- a/assets/manifest.webmanifest
+++ b/templates/manifest.webmanifest
@@ -1,4 +1,4 @@
-{
+{% load static %}{
"name": "Osiris",
"short_name": "Osiris",
"description": "Medication and pulse tracking for Osiris the cat.",
@@ -10,19 +10,19 @@
"theme_color": "#202a35",
"icons": [
{
- "src": "/static/icons/icon-192.png",
+ "src": "{% static 'icons/icon-192.png' %}",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
- "src": "/static/icons/icon-512.png",
+ "src": "{% static 'icons/icon-512.png' %}",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
- "src": "/static/icons/icon-maskable-512.png",
+ "src": "{% static 'icons/icon-maskable-512.png' %}",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
diff --git a/tracker/tests.py b/tracker/tests.py
index 812101a..979fcaf 100644
--- a/tracker/tests.py
+++ b/tracker/tests.py
@@ -1,5 +1,7 @@
+import json
from datetime import timedelta
+from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
@@ -246,6 +248,18 @@ class PwaTests(TrackerTestCase):
self.assertContains(response, 'rel="manifest"')
self.assertContains(response, "serviceWorker.register")
+ def test_the_manifest_is_valid_json_pointing_at_real_icons(self):
+ response = self.client.get(reverse("manifest"))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response["Content-Type"], "application/manifest+json")
+
+ manifest = json.loads(response.content)
+ self.assertEqual(manifest["start_url"], reverse("tracker:today"))
+ self.assertEqual(len(manifest["icons"]), 3)
+ for icon in manifest["icons"]:
+ self.assertTrue(icon["src"].startswith(settings.STATIC_URL))
+
def test_the_login_page_is_installable_too(self):
# The app opens on the login screen when the session lapses.
response = self.client.get("/admin/login/")
diff --git a/uv.lock b/uv.lock
index dd9f7ce..644efa0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -160,6 +160,7 @@ source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "django-ninja" },
+ { name = "whitenoise" },
]
[package.dev-dependencies]
@@ -173,6 +174,7 @@ dev = [
requires-dist = [
{ name = "django", specifier = ">=5.2" },
{ name = "django-ninja", specifier = ">=1.4" },
+ { name = "whitenoise", specifier = ">=6.12.0" },
]
[package.metadata.requires-dev]
@@ -415,3 +417,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288be
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
]
+
+[[package]]
+name = "whitenoise"
+version = "6.12.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
+]