Lossless Changelog
fleet

Plane Is Live, and It Lied About Being Healthy for Ten Minutes

Project tracking joins the stack — and an agent now files work items into it over the API. Getting there meant learning that a 200 response from Plane means the proxy is alive, not that Plane works.

Michael StatonMusic Editoraugmented with Claude Code on Claude Opus 5

Why Care?

The stack could always hold your projects. It could not track the work of building them. Plane closes that — the Linear/Jira lane, self-hosted, running for us first.

But the reason it earned a slot in core/ is narrower than "we needed a tracker." It passed one specific test that has disqualified better-looking products: the REST API, webhooks and personal access tokens are free in the community edition. That is exactly where Docmost lost the knowledge-management slot — its API keys sat behind an Enterprise licence, and agent access is a headline feature of every stack we ship.

Proof rather than assertion: within an hour of deploying, an agent minted a token and filed eight work items over the API. The backlog in there was written by something that had just spent the evening finding the bugs it was reporting.

What's New?

  • Plane deployed for lossless — five Railway services, migrations clean, admin account live.

  • core/plane pinned at v1.4.1 (upstream makeplane/plane, not forked).

  • docs/plane/setup.md — a runbook that now describes a deployment that happened, with five gotchas that cost real time.

  • A new README category, Project & Issue Tracking, with the honest caveat that Plane's community edition has no SSO.

  • Agent write access — token and instance URL in ~/.secrets, declared in secretspec.toml.

  • An issue on Postiz's footprint, because measuring the fleet turned up something uncomfortable.

The bet that made it affordable

Plane's standard self-host topology is thirteen services: web, admin, space, api, worker, beat-worker, live, migrator, proxy, plus Postgres, Redis, RabbitMQ and MinIO. On Railway that is roughly ten services per client — by far the heaviest thing in the stack, and honestly enough to make Plane not worth it.

Plane also ships an all-in-one image that runs all nine app processes under supervisord in one container. That leaves four external dependencies and collapses Plane to a Twenty-shaped footprint:

plane            makeplane/plane-aio-community:v1.4.1   9 processes, supervisord
plane-postgres   postgres:15.7-alpine                   volume
plane-redis      valkey/valkey:7.2.11-alpine
plane-mq         rabbitmq:3.13.6-management-alpine      volume
plane-storage    Railway bucket                         replaces MinIO

RabbitMQ is the one genuinely new dependency Plane adds — AMQP_URL is required and the app will not boot without it. Datastore pins follow Plane's own compose rather than our house defaults: Postgres 15, not 16, and Valkey, not Redis.

A 200 that meant nothing

This is the part worth carrying forward.

After the first deploy, everything said healthy. /healthz returned 200. The Railway healthcheck passed. The deployment reported SUCCESS. A poller printed UP. Every automated signal agreed.

The page said:

🚧 Looks like Plane didn't start up correctly!

Plane's Caddy proxy answers 200 OK while serving that. So the proxy being alive and the application working are different facts, and only one of them is observable by curl.

It was caught because step 9 of the runbook says to load the page in a browser. Without that habit it would have shipped, and the failure would have surfaced as a confused human staring at a spinner.

A green pipeline is not evidence. Look at the thing.

What we broke, and how

The underlying failure was ours.

Migrations were running on first boot when a variable got changed. That triggered a redeploy which killed the migrator mid-transaction — and, worse, left two containers running migrations against the same database at once. Two Django migrators racing is what produced this:

ERROR: duplicate key value violates unique constraint "pg_type_typname_nsp_index"
ERROR: column "sort_order" of relation "stickies" already exists
ERROR: relation "global_views" does not exist

Django never records an interrupted migration, so it retried forever against a schema that already had the objects. Supervisord restarted the migrator every ten seconds, each pass making it worse.

Recovery, safe only because no data existed yet: drop and recreate the database. Two mechanics cost a cycle each — DROP DATABASE cannot run inside a transaction block, so psql -c "DROP …; CREATE …;" fails and they must be separate calls; and WITH (FORCE) is what evicts the crash-looping connections that otherwise block the drop.

Then a second, quieter failure: two deployments failed their healthcheck at exactly 600 seconds because Railway probes $PORT while Plane's Caddy binds :80. Railway rolled back to the container from before the database wipe — which is what the operator was clicking on while everything looked fine. PORT=80 fixed it. Same shape as Twenty's PORT/NODE_PORT trap, which cost a failed deploy the first time too.

The setup page needs a trailing slash

Worth stating plainly because it will recur on every AIO deployment.

Plane's own "Get started" button links to /god-mode. The admin app's router is mounted at /god-mode/. The bare path serves HTML that renders nothing, and the console says exactly why:

<Router basename="/god-mode/"> is not able to match the URL "/god-mode"
because it does not start with the basename, so the <Router> won't render anything.

Compounding it, the admin shell ships no cache-control header — only an ETag and a last-modified weeks in the past — so a browser that loaded the page while it was broken will happily keep serving that copy.

Go to /god-mode/ directly, in a private window. Don't use the button.

What measuring the fleet turned up

Checking whether Plane was affordable meant measuring everything, and the answer reframed how we think about cost:

Memory is 93% of the Railway bill. $48.56 of $52.40 last month. CPU was $2.39. Egress was nine cents. twenty-server averages 0.0004 vCPU and costs the same as if it were busy.

So cost tracks resident memory of always-on containers, not users. Which makes the per-seat pitch on the landing page structurally true rather than marketing.

It also surfaced this:

the-water-foundationmemory
postiz2,648 MB
temporal + temporal-postgres465 MB
postiz datastores56 MB
Postiz and dependencies3,169 MB — 56% of the project

That instance of Postiz has never been usable — its FRONTEND_URL sits on *.up.railway.app, a Public Suffix, so browsers refuse its auth cookie. The largest line item in that project is a tool nobody can open. Filed as an issue rather than acted on; whether the capability is wanted is not an engineering call.

The tracker is already tracking itself

Eight work items now sit in Plane, filed over the API, carrying evidence rather than titles — the memory numbers, the cookie-domain explanation, the exact command to run:

SELF-1  high    Confirm Plane sends invitation email
SELF-2  high    TWF Postiz is deployed but cannot be logged into
SELF-3  high    Prove SMTP with one live send across all four Twenty instances
SELF-4  high    Pull dangling didi.sh DNS records for deleted hub services
SELF-5  medium  Reconcile what is deployed against what clients.ts routes
SELF-6  medium  Add Plane to clients.ts now that it is reachable
SELF-7  medium  Verify pricing on the self-host-stack catalog
SELF-8  low     Delete orphaned temporal-postgres in reach-edu

Deliberately not done

  • No clients.ts entry yet. That file is the portal's routing table — what a client can open — not an inventory of what runs. TWF's Postiz is the cautionary tale: deployed, healthy, unusable, unrouted, unnoticed for eleven days. Plane gets its route in a separate, deliberate change.

  • Email unproven. Plane has never sent a message. Given Twenty's LOGGER default stranded a stakeholder for four days across four instances, assume it is broken until an invitation lands in a real inbox. That is SELF-1.

  • Backups not wired. The pg-dump-plane service does not exist yet.