Plane is a self-hostable project management tool covering issues, cycles (sprints), modules, roadmaps, and pages. The stack is Django for the API, React with React Router for the frontend, and a separate Node.js service for real-time collaboration.
Why I starred it
Most Jira alternatives either mimic Jira too closely or swing so far in the other direction that they lose the features teams actually need. Plane lands in the middle: it has the structured data model of Jira (states, priorities, estimates, labels, blockers) while shipping an interface that doesn't feel like it was designed in 2004.
What caught my attention wasn't the feature list — it was the monorepo layout. When a project separates concerns this clearly at the package level, it usually signals something deliberate about the architecture.
How it works
The repo lives at apps/ and packages/. The main API (apps/api) is a Django project. The frontend (apps/web) is React with Vite. The interesting one is apps/live — a standalone Node.js service that handles real-time document collaboration.
The issue model is more interesting than it looks.
apps/api/plane/db/models/issue.py has a save() override that uses a PostgreSQL advisory lock for sequence ID generation:
lock_key = convert_uuid_to_integer(self.project.id)
with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
last_sequence = IssueSequence.objects.filter(project=self.project).aggregate(
largest=models.Max("sequence")
)["largest"]
self.sequence_id = last_sequence + 1 if last_sequence else 1
The advisory lock is scoped per-project (converted from UUID to integer) and is transaction-level, so it's released automatically on commit or rollback. This avoids the race condition in naive "get max + 1" patterns without reaching for a separate counter table or a sequence per project in Postgres. It does mean every new issue creation serializes at the project level, but at the scale this tool operates at, that's a reasonable tradeoff.
The issue model also stores description in three formats: description_json (ProseMirror JSON), description_html, and description_binary (Yjs binary). That triple-write happens to support both the REST API consumers (JSON/HTML) and the real-time editor (binary).
The live service runs on Hocuspocus.
apps/live/src/hocuspocus.ts bootstraps a HocusPocusServerManager singleton wrapping the Hocuspocus server — a collaboration backend built on Yjs. The interesting part is in apps/live/src/extensions/database.ts, the fetchDocument / storeDocument handlers:
const fetchDocument = async ({ context, documentName: pageId }) => {
const service = getPageService(context.documentType, context);
const response = await service.fetchDescriptionBinary(pageId) as Buffer;
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
// Fall back: convert stored HTML to Yjs binary on first access
const pageDetails = await service.fetchDetails(pageId);
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(
pageDetails.description_html ?? "<p></p>",
pageDetails.name
);
// ... save the converted binary back and return it
}
return binaryData;
};
If there's no binary data (e.g., an older document created before the real-time editor), it falls back to the stored HTML, converts it to a Yjs document, saves the binary back, and returns it. Zero-downtime migration of document format, handled transparently per document on first access.
Background tasks are Celery.
The apps/api/plane/bgtasks/ folder has 30+ Celery tasks. Issue activity tracking (issue_activities_task.py) is particularly meticulous — there are individual track_* functions per field (name, description, assignees, labels, etc.) that diff old vs. new values and emit structured IssueActivity records. Automation tasks (issue_automation_task.py) handle things like auto-archiving completed issues older than N months, using complex Q filter chains that account for active cycles and modules before archiving.
The editor is a separate package.
packages/editor is a TipTap-based rich text editor with custom extensions under src/core/extensions/. The slash commands live at src/core/extensions/slash-commands/ with a proper command palette implementation. There's also a work-item-embed extension for embedding issue references inline — so you can drop a live issue card inside a page document.
Using it
Self-hosting via Docker Compose is straightforward:
git clone https://github.com/makeplane/plane
cd plane
cp .env.example .env
docker compose up -d
The docker-compose.yml spins up six containers: the Django API, the web frontend, the live collaboration service, a proxy (Nginx), PostgreSQL, and Redis. Redis handles both Celery task queuing and real-time presence data for Hocuspocus.
The REST API follows standard Django REST Framework patterns. Creating an issue:
curl -X POST https://your-plane-instance/api/v1/workspaces/{slug}/projects/{id}/issues/ \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name": "Fix login timeout", "priority": "high", "state": "<state-uuid>"}'
Rough edges
The description_binary field on Issue stores the Yjs document as a binary blob in PostgreSQL. That's fine for correctness but means you can't query or index document content from the database. Full-text search would need to rely on description_stripped.
The tests in apps/api/plane/tests/ and apps/live/tests/ exist but coverage is uneven. The live service has a vitest.config.ts and some test files, but the Django API tests are thin relative to the model surface area.
The monorepo uses Turborepo (turbo.json), but the dependency graph is flat — everything builds in sequence. For a codebase this size, you'd expect to see more parallelism.
The packages/ layer has an ee/ (Enterprise Edition) split in several places, which means some features are community edition only and require the commercial version. The boundaries aren't always obvious from the outside.
Bottom line
If you're self-hosting project management for a small team and want to own your data, Plane is the most mature option in this space. The real-time collaboration architecture using Hocuspocus/Yjs is well-considered, and the advisory-lock approach to sequence IDs is exactly the kind of detail that shows someone thought about production correctness. The commercial split is annoying but honest.
