Migrating from Linear to self-hosted Plane
Linear worked well for me, but I wanted project data on infrastructure I control. Something that fits the rest of the homelab and lets me wire up automation and AI tooling on my own terms. After looking at a few options, I settled on Plane: Kanban-first workflows, priorities and dates in the product, and a GraphQL API I could actually build against.
This post is the story of deploying Plane on my Proxmox cluster, migrating two teams’ worth of Linear issues, and getting the official MCP server usable from Cursor, including the sharp edges nobody puts in the marketing copy.
Problem
I needed three things to line up:
- Installation - Plane running in the same Docker-in-LXC pattern as Gitea, Firefly III, and the rest, with Traefik in front and DNS and monitoring wired in like every other service.
- Migration - All historical issues moved over without losing meaning in descriptions, status, or priority.
- Automation - A path to MCP or API access so assistants and scripts can create and query work items without clicking through the UI.
Linear’s export story is fine for backup; it’s not where I wanted to live day to day. Self-hosted Plane checked the boxes if I could get the stack stable and the data across.
Investigation
Importer: Plane documents a Linear importer for workspace admins. On self-hosted Community Edition, that path isn’t available the same way as on Plane Cloud / commercial builds. I confirmed I’d need my own migration story rather than a one-click import.
MCP: Plane ships an official MCP server (makeplane/plane-mcp-server). Plan A was to use it as-is before writing anything custom.
Runtime: I knew from experience that “just PostgreSQL and Redis” is rarely the whole story for app stacks. I budgeted time for surprises after reading the compose examples and release notes.
Solution: infrastructure
I deployed Plane in an LXC on the Proxmox cluster, same playbook style as my other services: Docker Compose, persistent volumes, Traefik for HTTPS, Pi-hole for internal names, Uptime Kuma and Homepage for visibility.
The compose layout ended up with PostgreSQL, Redis, RabbitMQ, the Plane API, and the Next.js frontend split into separate services. RabbitMQ wasn’t optional in practice; the backend expects AMQP for its worker story, not “Postgres + Redis only.”
Ansible owns the deployment: service definition, role, playbook, Traefik dynamic config, and state entries alongside the rest of the homelab inventory. I also wrote a short procedure doc so future-me doesn’t have to reverse-engineer the container from memory.
What broke (and how I fixed it)
USE_MINIO: Plane parses this as an integer string ("0" / "1"). I had it set to "false" from a boolean-style template. The backend threw ValueError: invalid literal for int() with base 10: 'false'. Switching to 0/1 fixed boot.
PostgreSQL password drift: After rotating secrets in Ansible, the DB volume had been initialized with an older password than the one the API was using. The honest fix was to remove the Postgres data directory and let it re-init with the credentials the stack now uses. That was acceptable in my case because this was pre-production; I wouldn’t do that casually on a live dataset.
Frontend restart loop: I hit a restart loop on an early frontend image/tag while the UI was coming up. I briefly used an explicit command (node web/server.js web) in Compose while debugging. The compose I run today uses makeplane/plane-frontend:stable with no command override (the image default is enough). If you pull a different tag and see the same loop, check Plane’s issues or try that command as a temporary override.
Traefik routing: While the frontend was broken, I temporarily pointed Traefik at the API port. After the frontend fix, I moved the router to the web UI on port 3000 so browsers hit the real app.
Secret length: The first generated app secret was too short for comfort. I switched to a longer key (64 characters via Ansible’s password lookup) so session signing wasn’t the weakest link.
Solution: migrating from Linear
I wrote a Python script that talks to Linear’s GraphQL API (paginated), maps statuses into Plane states, converts Markdown descriptions to HTML for Plane, preserves priorities, and adds a line of metadata linking back to the original Linear issue for traceability. Rate limits get retries and polite delays.
Results:
- 178 issues pulled from Linear (across two teams).
- 178 issues created in Plane on the first pass, then 62 duplicates from earlier test runs.
- A small cleanup script matched duplicates by title, kept the newest
created_at, deleted the rest. - 177 unique issues in Plane with zero failed creates after cleanup.
So: not a flawless one-shot import, but a deterministic one. Re-running and deduping is boring; losing data isn’t.
Verification: MCP and Cursor
I configured Plane’s official MCP server in Cursor with my self-hosted URL and a workspace API key. Most tools work. Listing projects, pulling issues, and commenting all worked fine.
Create issue is different. Cursor’s MCP client has a known bug where it expects a parameter named title for create_issue even though Plane’s server correctly exposes name. That mismatch means issue creation through MCP fails validation in Cursor even though the server schema is right.
Workaround: For creates, I bypass MCP and call Plane’s REST API with X-API-Key, the same approach the migration tooling uses. Everything else can stay on MCP.
Lessons learned
- Read the compose requirements for the exact edition you run. Community vs commercial changes what importers you get; assuming parity with cloud docs wastes an afternoon.
- Treat boolean env vars as hostile input until you’ve seen the parser.
"false"is not always false. - Queue infrastructure is part of the app, not a nice-to-have; if the docs mention RabbitMQ, believe them early.
- Dedupe is a feature when you’re iterating on a migration against a live workspace.
- MCP is a contract between three parties (server, client, model). When the client is picky about parameter names, direct HTTP is still a valid “integration.”
Plane is up, issues live in my stack, and the migration path is code I can run again if I ever need to replay or audit. That was the goal.
Related reading
Replacing Firefly III with Actual Budget
Why I swapped out a finance app I never opened for one I actually use, and how the Docker-in-LXC deployment on Proxmox went.
Self-hosting my ebooks with Kavita
I wanted my EPUBs, PDFs, and Kindle purchases in one self-hosted library I could read from any device. Kavita became that library: a clean web reader, OPDS for mobile, and the work of getting Kindle books in.
Self-hosting my audiobooks with Audiobookshelf
I wanted my Audible library streamed to my phone, position-synced, and not locked inside one app. Audiobookshelf got a container in the homelab. Here is the deploy, the WebSocket gotcha, the iOS client I picked, and the work of getting an Audible library out of DRM.
Ready to Transform Your Career?
Let's work together to unlock your potential and achieve your professional goals.