Planka is a self-hosted, Trello-style kanban board with real-time sync and a REST API — no per-seat billing, runs on your own Postgres server.
Trello's free tier stops at ten boards per workspace, and past that point every extra board rides on a per-seat subscription that keeps climbing as the roster grows. Planka runs the same drag-and-drop board on a server the team already pays for, with no seat count, board cap or per-user invoice baked into the software itself.
Planka covers the part of Trello a kanban team actually lives in: boards, lists and cards with real-time sync over websockets, markdown card descriptions, comments, attachments, due dates and a stopwatch. It ships a documented REST API and webhooks, so scripting a card creation flow from a form or a CI pipeline doesn't need a paid automation add-on. Trello's edge is everything built around the board — a large Power-Up marketplace, native mobile apps, and Butler rules that fire on card moves without any code. Planka has no native mobile app and no built-in rule engine; anything Butler-shaped has to go through the API.
The cost story is where the two diverge hardest. A fifteen-person team on Trello's Standard plan pays per seat every month, and the bill grows as contractors and clients get added to boards. Planka's Community edition costs whatever the container running it costs, flat, regardless of headcount — but calendar and timeline views, card templates, guest roles and SSO all sit behind Planka's own paid Pro tier, so "free" only holds while the team stays inside the Community feature set.
Is Planka free for commercial use? Community edition is free to self-host and use commercially; calendar, timeline, templates and SSO require the separate paid Pro tier.
Planka vs Trello — which should I choose? Trello if the team wants zero setup and a Power-Up marketplace; Planka if per-seat pricing is the problem and someone can run a Postgres-backed container.
Does Planka need PostgreSQL? Yes — it's the only supported database today, and there's no lighter SQLite path even for a single-user install.
Planka is a solid pick when the goal is a Trello-shaped board without a growing per-seat bill and the team can operate a small Postgres instance. Community stays free under an MIT designation in the project's metadata, though the Fair Use terms and the paid Pro tier are worth reading before anyone plans to resell it as a hosted product; the repository is actively maintained, with commits landing within the last few days.
We started this exact setup from scratch in a clean Linux machine on Nov 2, 2026 and checked it through Planka's own API: every check passed. Recipes are re-checked regularly and removed if they stop working.
mkdir -p planka && cd planka
curl -fsSL https://open-source.store/tools/planka/compose.yml -o compose.ymlcat > .env <<EOF
BASE_URL=http://localhost:3000
SECRET_KEY=$(openssl rand -hex 32)
[email protected]
DEFAULT_ADMIN_PASSWORD=$(openssl rand -hex 12)
DEFAULT_ADMIN_NAME=Admin
DEFAULT_ADMIN_USERNAME=admin
POSTGRES_PASSWORD=$(openssl rand -hex 12)
EOFBASE_URL — Public base URL where Planka is accessed; set to your server's public domain or IP in production.SECRET_KEY — Secret encryption key used to sign JWT access tokens and session data. Generated above; keep it secret.DEFAULT_ADMIN_EMAIL — Email address for the initial administrator account.DEFAULT_ADMIN_PASSWORD — Initial password for the administrator account. Generated above; keep it secret.DEFAULT_ADMIN_NAME — Display name of the initial administrator account.DEFAULT_ADMIN_USERNAME — Username of the initial administrator account.POSTGRES_PASSWORD — Password of the PostgreSQL database. Generated above; keep it secret.docker compose up -dservices:
planka:
image: ghcr.io/plankanban/planka:2.2.1
restart: on-failure
volumes:
- data:/app/data
ports:
- "3000:1337"
environment:
BASE_URL: ${BASE_URL}
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres/planka
SECRET_KEY: ${SECRET_KEY}
DEFAULT_ADMIN_EMAIL: ${DEFAULT_ADMIN_EMAIL}
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD}
DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME}
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME}
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "/app/healthcheck.js"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
postgres:
image: postgres:16-alpine
restart: on-failure
volumes:
- db-data:/var/lib/postgresql/data
environment:
POSTGRES_DB: planka
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d planka"]
interval: 5s
timeout: 5s
retries: 5
volumes:
data:
db-data:
Go, gRPC and Protobuf done right, LLM agents in production, and the hard parts of shipping a first product.
Show that Planka is listed as an open-source alternative. Paste into your README:
Ubuntu 24.04 · Apple container, Docker 29.1.3, Compose 2.40.3+ds1-0ubuntu1~24.04.1. Images pulled in 33 s: ghcr.io/plankanban/planka:2.2.1, postgres:16-alpine.
Based on the upstream setup. Official compose. Image pinned to 2.2.1; database password, secret key, and admin credentials moved to .env.
postgres-1 | 2026-11-02T02:10:50.574933293Z selecting default time zone ... UTC postgres-1 | 2026-11-02T02:10:50.575294710Z creating configuration files ... ok planka-1 | 2026-11-02T02:10:56.917294421Z debug: It looks like your `sails.config.sockets.onlyAllowOrigins` array only includes planka-1 | 2026-11-02T02:10:56.917380838Z debug: references to the `localhost` origin. This is completely valid, but be sure planka-1 | 2026-11-02T02:10:56.917383796Z debug: to add any other origins to this list that you'd like to accept socket planka-1 | 2026-11-02T02:10:56.917384838Z debug: connections from! planka-1 | 2026-11-02T02:10:56.917393796Z debug: planka-1 | 2026-11-02T02:10:57.523467088Z 2026-11-02 02:10:57 [W] Custom terms not found, falling back to template planka-1 | 2026-11-02T02:11:18.320001041Z 2026-11-02 02:11:18 [E] Unable to parse HTTP body- error occurred :: `SyntaxError: Unexpected token '@', "@\r\n` + planka-1 | 2026-11-02T02:11:18.320015125Z '" is not valid JSON\n' + planka-1 | 2026-11-02T02:11:18.320016625Z ' at JSON.parse (<anonymous>)\n' + planka-1 | 2026-11-02T02:11:18.320017875Z ' at createStrictSyntaxError (/app/node_modules/body-parser/lib/types/json.js:169:10)\n' + planka-1 | 2026-11-02T02:11:18.320019208Z ' at parse (/app/node_modules/body-parser/lib/types/json.js:90:15)\n' + planka-1 | 2026-11-02T02:11:18.320020083Z ' at /app/node_modules/body-parser/lib/read.js:128:18\n' + planka-1 | 2026-11-02T02:11:18.320020875Z ' at AsyncResource.runInAsyncScope (node:async_hooks:227:14)\n' + planka-1 | 2026-11-02T02:11:18.320021708Z ' at invokeCallback (/app/node_modules/raw-body/index.js:238:16)\n' + postgres-1 | 2026-11-02T02:10:50.641578752Z running bootstrap script ... ok postgres-1 | 2026-11-02T02:10:50.726277543Z sh: locale: not found postgres-1 | 2026-11-02T02:10:50.726311127Z 2026-11-02 02:10:50.724 UTC [34] WARNING: no usable system locales were found postgres-1 | 2026-11-02T02:10:50.912828085Z performing post-bootstrap initialization ... ok postgres-1 | 2026-11-02T02:10:50.950993502Z initdb: warning: enabling "trust" authentication for local connections postgres-1 | 2026-11-02T02:10:50.951004835Z initdb: hint: You can change this by editing pg_hba.conf or using the option -A, or --auth-local and --auth-host, the next time you run initdb. postgres-1 | 2026-11-02T02:10:50.951018293Z syncing data to disk ... ok postgres-1 | 2026-11-02T02:10:50.951020293Z postgres-1 | 2026-11-02T02:10:50.951021252Z postgres-1 | 2026-11-02T02:10:50.951022168Z Success. You can now start the database server using: postgres-1 | 2026-11-02T02:10:50.951023168Z postgres-1 | 2026-11-02T02:10:50.951024043Z pg_ctl -D /var/lib/postgresql/data -l logfile start postgres-1 | 2026-11-02T02:10:50.951024960Z postgres-1 | 2026-11-02T02:10:50.974090085Z waiting for server to start....2026-11-02 02:10:50.974 UTC [40] LOG: starting PostgreSQL 16.15 on aarch64-unknown-linux-musl, compiled by gcc (Alpine 15.2.0) 15.2.0, 64-bit postgres-1 | 2026-11-02T02:10:50.974857293Z 2026-11-02 02:10:50.974 UTC [40] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432" postgres-1 | 2026-11-02T02:10:50.976974835Z 2026-11-02 02:10:50.976 UTC [43] LOG: database system was shut down at 2026-11-02 02:10:50 UTC postgres-1 | 2026-11-02T02:10:50.979130960Z 2026-11-02 02:10:50.979 UTC [40] LOG: database system is ready to accept connections postgres-1 | 2026-11-02T02:10:51.065709127Z done postgres-1 | 2026-11-02T02:10:51.065732210Z server started postgres-1 | 2026-11-02T02:10:51.096231085Z CREATE DATABASE postgres-1 | 2026-11-02T02:10:51.097658418Z postgres-1 | 2026-11-02T02:10:51.097676127Z postgres-1 | 2026-11-02T02:10:51.097734960Z /usr/local/bin/docker-entrypoint.sh: ignoring /docker-entrypoint-initdb.d/* postgres-1 | 2026-11-02T02:10:51.097738627Z postgres-1 | 2026-11-02T02:10:51.098081960Z waiting for server to shut down....2026-11-02 02:10:51.098 UTC [40] LOG: received fast shutdown request postgres-1 | 2026-11-02T02:10:51.098885752Z 2026-11-02 02:10:51.098 UTC [40] LOG: aborting any active transactions postgres-1 | 2026-11-02T02:10:51.100717002Z 2026-11-02 02:10:51.100 UTC [40] LOG: background worker "logical replication launcher" (PID 46) exited with exit code 1 postgres-1 | 2026-11-02T02:10:51.100768252Z 2026-11-02 02:10:51.100 UTC [41] LOG: shutting down postgres-1 | 2026-11-02T02:10:51.101453085Z 2026-11-02 02:10:51.101 UTC [41] LOG: checkpoint starting: shutdown immediate postgres-1 | 2026-11-02T02:10:51.121951960Z 2026-11-02 02:10:51.121 UTC [41] LOG: checkpoint complete: wrote 926 buffers (5.7%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.006 s, sync=0.014 s, total=0.022 s; sync files=301, longest=0.007 s, average=0.001 s; distance=4283 kB, estimate=4283 kB; lsn=0/1925D18, redo lsn=0/1925D18 postgres-1 | 2026-11-02T02:10:51.124945668Z 2026-11-02 02:10:51.124 UTC [40] LOG: database system is shut down postgres-1 | 2026-11-02T02:10:51.198827085Z done postgres-1 | 2026-11-02T02:10:51.198835043Z server stopped postgres-1 | 2026-11-02T02:10:51.199239960Z postgres-1 | 2026-11-02T02:10:51.199244168Z PostgreSQL init process complete; ready for start up. postgres-1 | 2026-11-02T02:10:51.199245377Z postgres-1 | 2026-11-02T02:10:51.211166710Z 2026-11-02 02:10:51.211 UTC [1] LOG: starting PostgreSQL 16.15 on aarch64-unknown-linux-musl, compiled by gcc (Alpine 15.2.0) 15.2.0, 64-bit postgres-1 | 2026-11-02T02:10:51.211299627Z 2026-11-02 02:10:51.211 UTC [1] LOG: listening on IPv4 address "0.0.0.0", port 5432 postgres-1 | 2026-11-02T02:10:51.211303210Z 2026-11-02 02:10:51.211 UTC [1] LOG: listening on IPv6 address "::", port 5432 postgres-1 | 2026-11-02T02:10:51.212384835Z 2026-11-02 02:10:51.212 UTC [1] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432" postgres-1 | 2026-11-02T02:10:51.214944752Z 2026-11-02 02:10:51.214 UTC [56] LOG: database system was shut down at 2026-11-02 02:10:51 UTC postgres-1 | 2026-11-02T02:10:51.217041418Z 2026-11-02 02:10:51.217 UTC [1] LOG: database system is ready to accept connections planka-1 | 2026-11-02T02:11:18.320022541Z ' at done (/app/node_modules/raw-body/index.js:227:7)\n' + planka-1 | 2026-11-02T02:11:18.320023375Z ' at IncomingMessage.onEnd (/app/node_modules/raw-body/index.js:287:7)\n' + planka-1 | 2026-11-02T02:11:18.320024166Z ' at IncomingMessage.emit (node:events:509:28)\n' + planka-1 | 2026-11-02T02:11:18.320024958Z ' at endReadableNT (node:internal/streams/readable:1736:12)\n' + planka-1 | 2026-11-02T02:11:18.320025833Z ' at process.processTicksAndRejections (node:internal/process/task_queues:90:21)' planka-1 | 2026-11-02T02:11:18.331619541Z 2026-11-02 02:11:18 [E] Unable to parse HTTP body- error occurred :: `SyntaxError: Unexpected token '@', "@\r\n` + planka-1 | 2026-11-02T02:11:18.331627833Z '" is not valid JSON\n' + planka-1 | 2026-11-02T02:11:18.331629125Z ' at JSON.parse (<anonymous>)\n' + planka-1 | 2026-11-02T02:11:18.331630250Z ' at createStrictSyntaxError (/app/node_modules/body-parser/lib/types/json.js:169:10)\n' + planka-1 | 2026-11-02T02:11:18.331631208Z ' at parse (/app/node_modules/body-parser/lib/types/json.js:90:15)\n' + planka-1 | 2026-11-02T02:11:18.331632041Z ' at /app/node_modules/body-parser/lib/read.js:128:18\n' + planka-1 | 2026-11-02T02:11:18.331632958Z ' at AsyncResource.runInAsyncScope (node:async_hooks:227:14)\n' + planka-1 | 2026-11-02T02:11:18.331633875Z ' at invokeCallback (/app/node_modules/raw-body/index.js:238:16)\n' + planka-1 | 2026-11-02T02:11:18.331634708Z ' at done (/app/node_modules/raw-body/index.js:227:7)\n' + planka-1 | 2026-11-02T02:11:18.331635500Z ' at IncomingMessage.onEnd (/app/node_modules/raw-body/index.js:287:7)\n' + planka-1 | 2026-11-02T02:11:18.331636333Z ' at IncomingMessage.emit (node:events:509:28)\n' + planka-1 | 2026-11-02T02:11:18.331645750Z ' at endReadableNT (node:internal/streams/readable:1736:12)\n' + planka-1 | 2026-11-02T02:11:18.331646750Z ' at process.processTicksAndRejections (node:internal/process/task_queues:90:21)' planka-1 | 2026-11-02T02:11:31.304419422Z 2026-11-02 02:11:31 [E] Unable to parse HTTP body- error occurred :: "SyntaxError: Expected ',' or '}' after property value in JSON at position 65 (line 1 column 66)\n" + planka-1 | 2026-11-02T02:11:31.304448089Z ' at JSON.parse (<anonymous>)\n' + planka-1 | 2026-11-02T02:11:31.304450047Z ' at parse (/app/node_modules/body-parser/lib/types/json.js:96:19)\n' + planka-1 | 2026-11-02T02:11:31.304451214Z ' at /app/node_modules/body-parser/lib/read.js:128:18\n' + planka-1 | 2026-11-02T02:11:31.304452172Z ' at AsyncResource.runInAsyncScope (node:async_hooks:227:14)\n' + planka-1 | 2026-11-02T02:11:31.304453006Z ' at invokeCallback (/app/node_modules/raw-body/index.js:238:16)\n' + planka-1 | 2026-11-02T02:11:31.304453839Z ' at done (/app/node_modules/raw-body/index.js:227:7)\n' + planka-1 | 2026-11-02T02:11:31.304454631Z ' at IncomingMessage.onEnd (/app/node_modules/raw-body/index.js:287:7)\n' + planka-1 | 2026-11-02T02:11:31.304455631Z ' at IncomingMessage.emit (node:events:509:28)\n' + planka-1 | 2026-11-02T02:11:31.304456381Z ' at endReadableNT (node:internal/streams/readable:1736:12)\n' + planka-1 | 2026-11-02T02:11:31.304457214Z ' at process.processTicksAndRejections (node:internal/process/task_queues:90:21)' planka-1 | 2026-11-02T02:11:31.305701214Z 2026-11-02 02:11:31 [E] Unable to parse HTTP body- error occurred :: "SyntaxError: Expected ',' or '}' after property value in JSON at position 65 (line 1 column 66)\n" + planka-1 | 2026-11-02T02:11:31.305709089Z ' at JSON.parse (<anonymous>)\n' + planka-1 | 2026-11-02T02:11:31.305710756Z ' at parse (/app/node_modules/body-parser/lib/types/json.js:96:19)\n' + planka-1 | 2026-11-02T02:11:31.305711839Z ' at /app/node_modules/body-parser/lib/read.js:128:18\n' + planka-1 | 2026-11-02T02:11:31.305712922Z ' at AsyncResource.runInAsyncScope (node:async_hooks:227:14)\n' + planka-1 | 2026-11-02T02:11:31.305713839Z ' at invokeCallback (/app/node_modules/raw-body/index.js:238:16)\n' + planka-1 | 2026-11-02T02:11:31.305714672Z ' at done (/app/node_modules/raw-body/index.js:227:7)\n' + planka-1 | 2026-11-02T02:11:31.305715464Z ' at IncomingMessage.onEnd (/app/node_modules/raw-body/index.js:287:7)\n' + planka-1 | 2026-11-02T02:11:31.305716339Z ' at IncomingMessage.emit (node:events:509:28)\n' + planka-1 | 2026-11-02T02:11:31.305717131Z ' at endReadableNT (node:internal/streams/readable:1736:12)\n' + planka-1 | 2026-11-02T02:11:31.305717964Z ' at process.processTicksAndRejections (node:internal/process/task_queues:90:21)' planka-1 | 2026-11-02T02:11:57.308144878Z 2026-11-02 02:11:57 [E] Unable to parse HTTP body- error occurred :: "SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)\n" + planka-1 | 2026-11-02T02:11:57.308161545Z ' at JSON.parse (<anonymous>)\n' + planka-1 | 2026-11-02T02:11:57.308163420Z ' at parse (/app/node_modules/body-parser/lib/types/json.js:96:19)\n' + planka-1 | 2026-11-02T02:11:57.308166795Z ' at /app/node_modules/body-parser/lib/read.js:128:18\n' + planka-1 | 2026-11-02T02:11:57.308167753Z ' at AsyncResource.runInAsyncScope (node:async_hooks:227:14)\n' + planka-1 | 2026-11-02T02:11:57.308168628Z ' at invokeCallback (/app/node_modules/raw-body/index.js:238:16)\n' + planka-1 | 2026-11-02T02:11:57.308169503Z ' at done (/app/node_modules/raw-body/index.js:227:7)\n' + planka-1 | 2026-11-02T02:11:57.308170295Z ' at IncomingMessage.onEnd (/app/node_modules/raw-body/index.js:287:7)\n' + planka-1 | 2026-11-02T02:11:57.308171128Z ' at IncomingMessage.emit (node:events:509:28)\n' + planka-1 | 2026-11-02T02:11:57.308171920Z ' at endReadableNT (node:internal/streams/readable:1736:12)\n' + planka-1 | 2026-11-02T02:11:57.308172753Z ' at process.processTicksAndRejections (node:internal/process/task_queues:90:21)' planka-1 | 2026-11-02T02:11:57.308333878Z 2026-11-02 02:11:57 [E] Unable to parse HTTP body- error occurred :: "SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)\n" + planka-1 | 2026-11-02T02:11:57.308340045Z ' at JSON.parse (<anonymous>)\n' + planka-1 | 2026-11-02T02:11:57.308346920Z ' at parse (/app/node_modules/body-parser/lib/types/json.js:96:19)\n' + planka-1 | 2026-11-02T02:11:57.308348086Z ' at /app/node_modules/body-parser/lib/read.js:128:18\n' + planka-1 | 2026-11-02T02:11:57.308349128Z ' at AsyncResource.runInAsyncScope (node:async_hooks:227:14)\n' + planka-1 | 2026-11-02T02:11:57.308349961Z ' at invokeCallback (/app/node_modules/raw-body/index.js:238:16)\n' + planka-1 | 2026-11-02T02:11:57.308350795Z ' at done (/app/node_modules/raw-body/index.js:227:7)\n' + planka-1 | 2026-11-02T02:11:57.308351586Z ' at IncomingMessage.onEnd (/app/node_modules/raw-body/index.js:287:7)\n' + planka-1 | 2026-11-02T02:11:57.308352378Z ' at IncomingMessage.emit (node:events:509:28)\n' + planka-1 | 2026-11-02T02:11:57.308353128Z ' at endReadableNT (node:internal/streams/readable:1736:12)\n' + planka-1 | 2026-11-02T02:11:57.308353961Z ' at process.processTicksAndRejections (node:internal/process/task_queues:90:21)'