Internal wiki for Free Law Project organizational documentation. A Django application that supports hierarchical directories, Markdown pages with versioning, granular permissions, passwordless email auth, and public-facing pages.
This project — including its code, tests, and this README — was vibe coded with Claude Code. It has not had extensive human review. Please read everything with skepticism!
# 1. Clone and enter the repo
git clone <repo-url> && cd wiki
# 2. Copy the dev environment file
cp .env.example .env.dev
# 3. Start everything
docker compose -f docker/wiki/docker-compose.yml up --build
# 4. Seed help pages (optional, run once)
docker compose -f docker/wiki/docker-compose.yml exec wiki-django \
python manage.py seed_help_pagesThe wiki is now running at http://localhost:8001. Visit /login/ and enter
any @free.law email. In development, the magic link is printed to the Django
console — look for the token= URL in the container logs.
The first user to sign in automatically becomes the system owner with unrestricted access to all content.
| Layer | Technology |
|---|---|
| Language | Python 3.13, Django 6.0 |
| Database | PostgreSQL 16 |
| CSS | Tailwind 3.x (built via npm) |
| JS | Alpine.js, HTMX, EasyMDE (all vendored, no CDN) |
| Templates | Django templates + django-cotton components |
| Task queue | None — daemon service + management commands |
| File storage | Local filesystem (dev), S3 via django-storages (prod) |
| Console (dev), Amazon SES (prod) | |
| Containers | Docker Compose for development |
| ASGI server | Gunicorn + Uvicorn workers (prod) |
wiki/
pages/ Page CRUD, history, diff, revert, search, file uploads
directories/ Hierarchical directory tree, breadcrumbs
users/ Passwordless @free.law auth, user profiles, settings
proposals/ Change proposals workflow
subscriptions/ Page change notifications, email unsubscribe
groups/ Group management
lib/ Shared utilities: permissions, markdown, storage
Settings follow CourtListener's split-file pattern. wiki/settings/__init__.py
uses wildcard imports to compose the final config from:
settings/
django.py Core Django settings
project/
email.py, logging.py, security.py, testing.py
third_party/
aws.py, sentry.py, waffle.py
All settings use environ.FileAwareEnv() for environment-variable-based
configuration.
- Docker (or a Python 3.13 environment with PostgreSQL 16)
- An AWS account with S3 and SES configured
- A domain with DNS and HTTPS configured (via a reverse proxy like Nginx or Caddy)
- Docker Compose (or equivalent) to run the daemon service
Create a .env file (or set environment variables directly). Every setting
below is read via django-environ's FileAwareEnv, so you can also use
Docker secrets by pointing to files (e.g., SECRET_KEY_FILE=/run/secrets/key).
| Variable | Description | Example |
|---|---|---|
SECRET_KEY |
Django secret key. Generate with python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" |
abc123... |
DEBUG |
Must be False in production |
False |
DEVELOPMENT |
Must be False in production. Controls S3 storage, SES email, debug toolbar, and more |
False |
ALLOWED_HOSTS |
Comma-separated list of domains | wiki.free.law |
BASE_URL |
Full base URL for email links | https://wiki.free.law |
DB_HOST |
PostgreSQL hostname | db.example.com |
DB_NAME |
PostgreSQL database name | wiki |
DB_USER |
PostgreSQL user | wiki_user |
DB_PASSWORD |
PostgreSQL password | (strong password) |
DB_SSL_MODE |
PostgreSQL SSL mode | require |
When DEVELOPMENT=False, Django uses S3 for both media uploads and static
files. You need two S3 buckets:
| Variable | Description | Default |
|---|---|---|
AWS_ACCESS_KEY_ID |
IAM credentials for S3 | — |
AWS_SECRET_ACCESS_KEY |
IAM credentials for S3 | — |
AWS_STORAGE_BUCKET_NAME |
Public bucket for static files | com-freelawproject-wiki-storage |
AWS_PRIVATE_STORAGE_BUCKET_NAME |
Private bucket for uploaded files | com-freelawproject-wiki-private-storage |
AWS_S3_CUSTOM_DOMAIN |
Custom domain for static file URLs (optional) | <bucket>.s3.amazonaws.com |
Static files bucket (AWS_STORAGE_BUCKET_NAME): Stores collected static
assets (CSS, JS, images). Files are served from the static/ prefix within the
bucket.
Private uploads bucket (AWS_PRIVATE_STORAGE_BUCKET_NAME): Stores
user-uploaded files (page attachments, images). All files are stored with
private ACL and served via 5-minute signed URLs — no public access needed.
For the static files bucket:
- Enable public access (or serve via CloudFront)
- No special CORS or lifecycle rules needed
For the private uploads bucket:
- Block all public access — files are served via signed URLs
- Suggested bucket policy: grant the IAM user
s3:GetObject,s3:PutObject,s3:DeleteObject, ands3:ListBucket - No CORS required unless the wiki is on a different domain than S3
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::com-freelawproject-wiki-storage",
"arn:aws:s3:::com-freelawproject-wiki-storage/*",
"arn:aws:s3:::com-freelawproject-wiki-private-storage",
"arn:aws:s3:::com-freelawproject-wiki-private-storage/*"
]
}
]
}When DEVELOPMENT=False, email is sent via Amazon SES (us-west-2 region).
| Variable | Description |
|---|---|
AWS_SES_ACCESS_KEY_ID |
IAM credentials for SES (can differ from S3 credentials) |
AWS_SES_SECRET_ACCESS_KEY |
IAM credentials for SES |
SES setup requirements:
- Verify your sending domain (
free.law) in the SES console - The sender address is
noreply@free.law(configured insettings/project/email.py) - If your SES account is in sandbox mode, you must also verify recipient addresses
- Request production access from AWS to send to unverified addresses
- The IAM user needs the
ses:SendRawEmailpermission
| Variable | Description |
|---|---|
SENTRY_DSN |
Sentry DSN for error reporting. Leave empty to disable |
| Variable | Description | Default |
|---|---|---|
TIMEZONE |
Server timezone | America/Los_Angeles |
MEDIA_ROOT |
Local media root (only used when DEVELOPMENT=True) |
wiki/assets/media/ |
STATIC_URL |
Static file URL prefix | static/ |
NUM_WORKERS |
Gunicorn worker count | 4 |
MAX_REQUESTS |
Gunicorn max requests before worker restart | 2500 |
WAFFLE_FLAG_DEFAULT |
Default for missing feature flags | False |
WAFFLE_SWITCH_DEFAULT |
Default for missing feature switches | True |
docker build -t wiki-django -f docker/django/Dockerfile .The Dockerfile:
- Installs Python dependencies via
uv - Installs Node dependencies and builds Tailwind CSS
- Copies the application code
- Runs as
www-datauser
Provision a PostgreSQL 16 instance (RDS, self-hosted, etc.) and create the database:
CREATE DATABASE wiki;
CREATE USER wiki_user WITH PASSWORD 'strong-password-here';
GRANT ALL PRIVILEGES ON DATABASE wiki TO wiki_user;Run migrations:
docker run --env-file .env wiki-django migrateThe entrypoint's fallthrough case passes arguments to manage.py, so
docker run wiki-django migrate is equivalent to python manage.py migrate.
Create the cache table (used for Django's database-backed cache):
docker run --env-file .env wiki-django createcachetableWhen DEVELOPMENT=False, static files are stored in S3. Run collectstatic
to upload them:
docker run --env-file .env wiki-django collectstatic --noinputThis uploads all static files to the static/ prefix of your
AWS_STORAGE_BUCKET_NAME bucket.
docker run -d \
--name wiki-django \
--env-file .env \
-p 8000:8000 \
wiki-django web-prodThis starts Gunicorn with Uvicorn workers (ASGI). Configuration:
- Workers:
NUM_WORKERSenv var (default: 4) - Timeout: 180 seconds
- Max requests:
MAX_REQUESTSenv var (default: 2500, with 100 jitter) - Bind:
0.0.0.0:8000
The first user to log in becomes the system owner.
The application listens on port 8000. Put it behind a reverse proxy (Nginx, Caddy, etc.) for HTTPS termination.
Key production security settings are enabled automatically when
DEVELOPMENT=False:
SESSION_COOKIE_SECURE = TrueCSRF_COOKIE_SECURE = TrueSECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")- HSTS: 2 years, with subdomains and preload
Nginx example:
server {
listen 443 ssl;
server_name wiki.free.law;
ssl_certificate /etc/letsencrypt/live/wiki.free.law/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/wiki.free.law/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 16M;
}
}The wiki runs periodic tasks via a daemon service (run_daemon management
command) that loops and executes tasks on a configurable schedule. Start it
alongside the web server:
docker run -d \
--name wiki-daemon \
--env-file .env \
wiki-django run_daemonThe daemon runs three tasks:
| Task | Default interval | Purpose |
|---|---|---|
sync_view_counts |
5 s | Aggregates PageViewTally rows into Page.view_count and deletes processed tallies. Avoids write contention on the Page table during reads. |
update_search_vectors |
30 s | Rebuilds PostgreSQL full-text search vectors for all pages, so search results stay current. |
cleanup |
6 hours | Runs miscellaneous cleanup tasks. |
Override intervals with environment variables (values in seconds):
| Variable | Default |
|---|---|
DAEMON_SYNC_VIEW_COUNTS_INTERVAL |
5 |
DAEMON_UPDATE_SEARCH_VECTORS_INTERVAL |
30 |
DAEMON_CLEANUP_INTERVAL |
21600 |
Populate the /help directory with built-in documentation:
docker exec wiki-django python manage.py seed_help_pagesThis is idempotent — safe to run multiple times.
# Django
SECRET_KEY=your-generated-secret-key-here
DEBUG=False
DEVELOPMENT=False
ALLOWED_HOSTS=wiki.free.law
BASE_URL=https://wiki.free.law
# Database
DB_HOST=your-postgres-host.example.com
DB_NAME=wiki
DB_USER=wiki_user
DB_PASSWORD=your-strong-password
DB_SSL_MODE=require
# S3 (file storage + static files)
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
AWS_STORAGE_BUCKET_NAME=com-freelawproject-wiki-storage
AWS_PRIVATE_STORAGE_BUCKET_NAME=com-freelawproject-wiki-private-storage
# SES (email)
AWS_SES_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SES_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
# Sentry (optional)
SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
# Workers
NUM_WORKERS=4
MAX_REQUESTS=2500No passwords. Users enter their @free.law email, receive a link with a
time-limited token (15 min), and click to sign in. Tokens are SHA-256 hashed
before storage. Non-@free.law emails are rejected at the form level.
Pages and directories share one URL space. The catch-all resolver
(resolve_path) checks in order:
- Does the path match a Directory? Render directory view.
- Does the last segment match a Page slug? Render page view.
- Does it match a SlugRedirect? 302 to the current URL.
-
Fixed routes (/login/, /search/, /api/, etc.) are registered first so
they take priority.
Pages link to each other using #page-slug syntax in Markdown content.
During rendering, the resolve_wiki_links preprocessor:
- Resolves known slugs to titled links:
#deploy-guidebecomes[Deploy Guide](/engineering/deploy-guide) - Resolves old slugs via the
SlugRedirecttable - Renders unknown slugs as red links (page doesn't exist yet)
The editor provides autocomplete: typing # + two characters triggers an
HTMX-powered dropdown of matching page titles.
When a page title changes, the slug updates and a SlugRedirect is created
mapping the old slug to the page. This means #old-slug wiki links and
bookmarks keep working indefinitely.
Three visibility levels:
| Level | Who can view |
|---|---|
| Public | Anyone, including anonymous visitors |
| Private | Page owner + system owner only |
| Restricted | Users with an explicit permission grant |
Permission types: View, Edit, Owner.
Permissions can be granted at the page level (PagePermission) or directory
level (DirectoryPermission). Directory permissions cascade — granting Edit
on /engineering/ gives Edit access to all pages and subdirectories within it.
The system owner (first user to sign in) has unrestricted access to everything.
Every edit creates a full-content PageRevision snapshot. Users can:
- View revision history with author and change message
- Compare any two revisions with a color-coded diff
- Revert to any previous revision (creates a new revision, notifies subscribers)
Directories also have versioned history (title and description). Reverting a directory does not change its visibility or editability settings.
Background work (syncing page view counts, updating search vectors, cleanup)
runs via a daemon service (run_daemon management command) instead of Celery
or django-q2. The daemon runs in its own container and loops on a configurable
schedule. See the Daemon Service section.
Alpine.js, HTMX, and EasyMDE are vendored as static files in
wiki/assets/static-global/js/. No external network requests for JS or CSS.
Uses prefers-color-scheme (Tailwind's darkMode: 'media'). No manual
toggle — the wiki follows the user's OS/browser setting.
Each page view creates a PageViewTally row. The daemon service periodically
sums tallies into Page.view_count and deletes the processed rows. This
avoids write contention on the Page table during high-traffic reads.
Tests run inside the Docker container against a disposable test database:
# Run the full suite
docker compose -f docker/wiki/docker-compose.yml exec wiki-django \
python -m pytest wiki/ -v
# Run tests for a single app
docker compose -f docker/wiki/docker-compose.yml exec wiki-django \
python -m pytest wiki/pages/tests.py -v
# Run a specific test class
docker compose -f docker/wiki/docker-compose.yml exec wiki-django \
python -m pytest wiki/users/tests.py::TestMagicLinkFlow -vTest files live alongside the code they test (wiki/pages/tests.py,
wiki/users/tests.py, etc.). Shared fixtures are in wiki/conftest.py.
| App | Tests | Covers |
|---|---|---|
pages |
162 | CRUD, history, diff, revert, slugs, search, uploads, markdown, wiki links, view counts, help page seeding |
users |
43 | Login form, magic link flow, logout, settings, profile model |
directories |
100 | Root view, directory detail, edit, history, diff, revert, model methods, page creation in directories |
lib |
36 | Permission checks (system owner, view, edit, restricted, directory inheritance) |
subscriptions |
16 | Subscribe/unsubscribe toggle, notifications, revert notifications, email content, unsubscribe landing |
# Run the daemon (periodic tasks: view counts, search vectors, cleanup)
docker exec wiki-django python manage.py run_daemon
# Seed help pages in /help directory (idempotent)
docker exec wiki-django python manage.py seed_help_pages
# Sync page view tallies into Page.view_count (also run by daemon)
docker exec wiki-django python manage.py sync_view_counts
# Update full-text search vectors for all pages (also run by daemon)
docker exec wiki-django python manage.py update_search_vectors
# Run migrations
docker exec wiki-django python manage.py migrate
# Create the cache table (needed once after initial DB setup)
docker exec wiki-django python manage.py createcachetable
# Collect static files to S3 (production)
docker exec wiki-django python manage.py collectstatic --noinput
# Open a Django shell
docker exec -it wiki-django python manage.py shelldocker compose -f docker/wiki/docker-compose.yml up starts:
| Service | Purpose | Port |
|---|---|---|
wiki-django |
Django dev server with auto-reload | localhost:8001 |
wiki-postgres |
PostgreSQL 16 | localhost:5433 |
wiki-tailwind |
Tailwind CSS watcher (rebuilds on file changes) | — |
wiki-daemon |
Periodic tasks (view counts, search vectors, cleanup) | — |
pip install pre-commit
pre-commit installRuns ruff (lint + format) and standard checks (large files, merge conflicts, trailing whitespace, etc.) on every commit.
Styles are in wiki/assets/tailwind/input.css using Tailwind's @layer
directives. The config is at wiki/assets/tailwind/tailwind.config.js.
The wiki-tailwind container watches for changes and rebuilds automatically.
Custom component classes: .btn-primary, .btn-outline, .btn-danger,
.btn-ghost, .card, .input-text, .alert-*, .wiki-content.
- Create the app under
wiki/(e.g.,wiki/newapp/) - Add it to
INSTALLED_APPSinwiki/settings/django.py - Create
migrations/__init__.pyin the app directory - Add URL patterns to
wiki/urls.py - Generate migrations:
docker exec wiki-django python manage.py makemigrations
Quick reference for going to production:
-
SECRET_KEYset to a strong random value -
DEBUG=FalseandDEVELOPMENT=False -
ALLOWED_HOSTSset to your domain(s) -
BASE_URLset to your HTTPS URL - PostgreSQL configured with
DB_SSL_MODE=require - S3 buckets created (public for static, private for uploads)
-
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYconfigured - SES domain verified, IAM credentials configured
-
collectstaticrun to upload static files to S3 -
migrateandcreatecachetablerun against the production database - Reverse proxy configured with HTTPS
- Daemon service running (
run_daemonfor view counts, search vectors, cleanup) - Sentry DSN configured (optional)
- First user logged in to become system owner
AGPL-3.0-only