# FreeLeaf: An free and open latex collaborative suite

> Source: <https://github.com/DeepMicroscopy/FreeLeaf>
> Published: 2026-10-19 06:59:22+00:00

An open and free, self-hostable, collaborative LaTeX editing platform. Self-host it on your own infrastructure for full digital sovereignty over your documents and data — no third-party cloud, no account with someone else's ToS standing between you and your own research.

Full spec: [`Plan.md`](https://github.com/DeepMicroscopy/FreeLeaf/blob/main/Plan.md). Working conventions for contributors/agents: [`CLAUDE.md`](https://github.com/DeepMicroscopy/FreeLeaf/blob/main/CLAUDE.md).

See [`Status.md`](https://github.com/DeepMicroscopy/FreeLeaf/blob/main/Status.md) for current progress.

Django + Django Ninja (`apps/api`) · React + Vite + TS (` apps/web`) · Node/` y-websocket` (`apps/collab`) · sandboxed TeX Live (` apps/compile`) · Postgres · MinIO · Mailpit. See `Plan.md` §4.

Requires Docker and Docker Compose.

```
docker compose up
```

This starts:

| Service | URL | 
|---|---|
| web (React SPA) | [http://localhost:5173](http://localhost:5173) | 
| api (Django + Ninja) | [http://localhost:8000/api/health](http://localhost:8000/api/health) | 
| collab (Yjs sync) | ws://localhost:1234 (health: [http://localhost:1234/health](http://localhost:1234/health) ) | 
| compile (sandboxed TeX Live runner) | [http://localhost:8100/health](http://localhost:8100/health) | 
| Mailpit (dev inbox for magic links) | [http://localhost:8025](http://localhost:8025) | 
| MinIO console | [http://localhost:9001](http://localhost:9001) | 

A fresh clone should build and start with no extra setup.

Always open the app at **[http://localhost:5173](http://localhost:5173)**, not `http://127.0.0.1:5173`. Browsers treat the two as different sites, so cookies from the api (at `localhost:8000`) won't reliably carry over if you mix them — this bites hardest in Safari.

```
cp .env.prod.example .env.prod
# fill in .env.prod: real secrets, your domain, Mailgun, ORCID production app
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
docker compose -f docker-compose.prod.yml build texlive
```

This builds production images (gunicorn + collected/whitenoise-served static for the api, an nginx-served Vite build for the web) and runs a single public-facing `web` container that reverse-proxies `/api`, `/admin`, `/static`, and `/collab` to the internal services — everything is same-origin in production, which avoids the CORS/cross-site-cookie fragility dev's separate `:5173`/`:8000` origins have. Only `web` is meant to be internet-facing; terminate TLS in front of it (a managed load balancer, or your own Caddy/nginx with certs proxying to `web`'s port) — `apps/web/nginx.conf` only serves plain HTTP.

**The second command is not optional.** `texlive` (the sandboxed compile worker image, built from `docker/texlive/Dockerfile`) is deliberately excluded from the profile the first command builds — it's never run as a long-lived container, only spawned per compile job by `compile` via the Docker Engine API, referencing the image by a plain local tag (`freeleaf-texlive:latest`, no registry). `up -d --build` skips it entirely, so compiles will fail with no image found until you build it explicitly, on the same Docker host `compile` runs on. Re-run this build any time `docker/texlive/Dockerfile` changes — nothing rebuilds it for you automatically.

`docker-compose.prod.yml` uses `name: freeleaf-prod` so it never shares containers/volumes with a dev stack running from the same checkout; its example port (`8001`) is chosen so both can run side by side during testing — map to `80`/` 443` for a real deployment.

**Running the web app and api on separate subdomains instead** (your own reverse proxy, not `apps/web/nginx.conf`) works too, but `DJANGO_ALLOWED_HOSTS`, `FRONTEND_URL`, `CORS_ALLOWED_ORIGINS`, and `CSRF_TRUSTED_ORIGINS` stop being the same value — see the comment block at the top of `.env.prod.example` for exactly which one needs which host. Getting `DJANGO_ALLOWED_HOSTS` wrong here surfaces as a 400 with nothing useful in the browser; check `docker compose -f docker-compose.prod.yml logs api` for `DisallowedHost` first. **If you take this route, your own reverse proxy also needs a read/proxy timeout comfortably above 100s** — a large document (big images, custom fonts) can legitimately take well over a minute to compile, and `apps/web/nginx.conf`'s own generous timeout for `/api` never applies here since traffic to the api host bypasses it entirely. A compile that fails right around the one-minute mark with a bare 500 and CORS errors in the browser console (CORS headers never got attached because the request died before Django could build a response) is this timeout being too short, not an actual CORS misconfiguration — see `gunicorn`'s `--timeout 100` in `docker-compose.prod.yml` for the timeout budget the rest of the chain is sized against.

**api**

```
cd apps/api
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/python manage.py migrate
.venv/bin/python manage.py runserver
```

**web**

```
pnpm install
pnpm --filter web dev
```

- api: `cd apps/api && python manage.py <cmd>` · test`pytest -q` · lint`ruff check .`
- web: `pnpm --filter web <script>` · lint`pnpm lint` · typecheck`pnpm typecheck`

`python3 scripts/make_promo_video.py` renders a silent, screenshot-driven promo video (for
sharing on LinkedIn, etc.) from the screenshots in `docs/assets/img` — crossfaded scenes with
animated on-screen text and a soft procedural music bed (no narration, no bundled audio).
Requires only `ffmpeg`. `--dry-run` prints the scene plan without rendering.

Compilation of untrusted user LaTeX is sandboxed per `Plan.md` §7 — no shell-escape, no network, ephemeral containers, resource limits. See `docs/security.md` (added in Phase 3).
