Installing and running
Board Planner is self-hosted: one deployment is one instance, with its own database, users and projects. This page takes you from an empty machine to a signed-in administrator, and then to a machine that runs an agent, on every way of running it we support.
| Path | You need | Start at |
|---|---|---|
| The published image, with Compose — the app and a MongoDB beside it | Docker with Compose 2.24+ | Run the published image |
| The published image, on your own — your MongoDB, your orchestration | Docker, and a MongoDB | Run the image against your own MongoDB |
| From source, without Docker — kept running by systemd or pm2 | Node.js 26, git, a MongoDB | Build it yourself |
| A hosting platform | An account there | On a hosting platform |
Whichever you pick, the rest is shared: the configuration that matters, HTTPS, the first account, connecting a machine, MCP, backups, upgrading and troubleshooting.
What it needs
Section titled “What it needs”- MongoDB 4.4 or newer. The queries deliberately avoid operators introduced in 5.0, so 4.4 is genuinely supported; 8.0 works too. See MongoDB.
- Docker, to run the published image — with Compose 2.24 or newer for the compose file, because older versions reject its
env_fileentry even when there is no.env.docker compose versionsays which you have. - Or Node.js 26 or newer, to build and run the app from a clone. There is no downloadable bundle of the web app: a release carries the image, the menubar app and the worker, so running without Docker means a clone.
AI Assist, email and the PM agent are optional and stay off until you configure them.
Run the published image
Section titled “Run the published image”Every release from 1.1.0 on publishes an image of the app to the GitHub Container Registry, public and built for linux/amd64 and linux/arm64. Earlier releases, 1.0.1 included, have none:
ghcr.io/rafalpodles/board-planner:latest # the newest releaseghcr.io/rafalpodles/board-planner:1.1.2 # one release, pinnedThe compose file from the repository runs that image with a MongoDB 4.4 beside it. No clone, no Node.js and no MongoDB of your own:
mkdir board-planner && cd board-plannercurl -fsSLO https://raw.githubusercontent.com/rafalpodles/board-planner/main/docker-compose.ymldocker compose up -ddocker compose logs app | grep "setup code"The code appears a few seconds after the start, once MongoDB answers. If grep finds nothing, wait and run it again, or follow the log with docker compose logs -f app. Open http://localhost:3000 and create the administrator with that code — see The first account.
Settings go in a .env file next to docker-compose.yml. Every variable in it reaches the app except NODE_ENV, PORT and HOSTNAME, which the compose file pins to production, 3000 and 0.0.0.0. Three belong to the compose file itself: APP_PORT moves the host port, BOARD_PLANNER_VERSION=1.1.2 pins a release instead of latest, and COOKIE_ALLOW_INSECURE defaults to auto, which issues a plain cookie only while the instance’s own addresses are http:// and the sign-in did not come over https:// — so a plain-HTTP instance can sign in, and one behind TLS gets the secure cookie with nothing more to set. That default needs an image of 1.1.2 or later; with a 1.1.1 image, set COOKIE_ALLOW_INSECURE=1 for plain HTTP. Generate the encryption key now, once, and keep it — a new key leaves everything the old one encrypted unreadable:
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .envdocker compose up -dRun docker compose up -d again after any change to .env; it recreates the app with the new values. For the variables the compose file lists under environment: — MONGODB_URI, APP_ORIGIN, PUBLIC_ORIGIN and the others it computes a default for — a value exported in the shell that runs docker compose wins over the one in .env.
Anywhere but your own laptop, the instance has an address of its own. Put it behind HTTPS and tell it that address: HTTPS in front of it has the .env for that.
Stop it with docker compose down. The database lives in the mongo-data volume and survives that; docker compose down -v deletes it.
Run the image against your own MongoDB
Section titled “Run the image against your own MongoDB”Your own orchestration needs only the image and environment variables — at the least MONGODB_URI, PUBLIC_ORIGIN, APP_ORIGIN and ENCRYPTION_KEY. Here, behind a proxy on the same machine, with MongoDB in a container of its own on a network the two share:
docker network create board-plannerdocker run -d --name mongo --network board-planner --restart unless-stopped \ -v mongo-data:/data/db mongo:4.4(umask 077; cat > board-planner.env <<EOFMONGODB_URI=mongodb://mongo:27017/boardplannerPUBLIC_ORIGIN=https://board.example.comAPP_ORIGIN=https://board.example.comTRUSTED_PROXY_HOPS=1ENCRYPTION_KEY=$(openssl rand -hex 32)EOF)docker run -d --name board-planner --network board-planner --restart unless-stopped \ -p 127.0.0.1:3000:3000 --env-file board-planner.env \ ghcr.io/rafalpodles/board-planner:1.1.2docker logs board-planner 2>&1 | grep "setup code"With a MongoDB you already run elsewhere, skip the first two commands and the --network, and put its address in MONGODB_URI. board-planner.env keeps the key out of your shell history and the machine’s process list — though docker inspect still shows it to anyone who can run Docker. Keep the file: it holds the only copy of the key. -p 127.0.0.1:3000:3000 publishes the app on the loopback interface only, so the proxy is the one way in. To try it on a laptop with no proxy, publish -p 3000:3000, set both origins to http://localhost:3000, drop TRUSTED_PROXY_HOPS and add COOKIE_ALLOW_INSECURE=1. As with compose, the setup code can take a few seconds to appear — docker logs -f board-planner follows the log.
Build it yourself
Section titled “Build it yourself”From source, with no Docker at all. A clone is the only source of the web app, so pin it to a release rather than running whatever main holds today:
git clone https://github.com/rafalpodles/board-planner.gitcd board-plannergit checkout v1.1.2npm cinpm run buildThe build needs no database and no settings. The app reads them from a .env in the checkout when it starts. Write one rather than copying .env.example, whose placeholder sk-... keys switch AI features on with keys that do not work:
(umask 077; cat > .env <<EOFMONGODB_URI=mongodb://127.0.0.1:27017/boardplannerPUBLIC_ORIGIN=https://board.example.comAPP_ORIGIN=https://board.example.comTRUSTED_PROXY_HOPS=1ENCRYPTION_KEY=$(openssl rand -hex 32)EOF)chmod 600 .envnode_modules/.bin/next start -H 127.0.0.1 -p 3000For a first look on your own machine, set both origins to http://localhost:3000, leave out TRUSTED_PROXY_HOPS and add COOKIE_ALLOW_INSECURE=1.
Bind it to 127.0.0.1 whenever a proxy is in front. With TRUSTED_PROXY_HOPS=1 the app believes the last X-Forwarded-For entry, and anyone who can reach port 3000 directly writes that entry themselves. npm start listens on every interface, so it is for a machine with nothing in front. From 1.1.2 it takes PORT from the environment, then from .env.production.local, .env.local, .env.production and .env, in that order, and uses 3000 when none names one; up to 1.1.1 it read only the environment, so on that release give the port on the command line as above.
The app stops when the terminal closes. To keep it running, give it to systemd or pm2.
Keep it running with systemd
Section titled “Keep it running with systemd”A system user to own it, and the steps above run as that user:
sudo useradd --system --create-home --home-dir /opt/board-planner --shell /usr/sbin/nologin boardplannersudo -u boardplanner git clone https://github.com/rafalpodles/board-planner.git /opt/board-planner/appsudo -u boardplanner -H bash -c 'cd /opt/board-planner/app && git checkout v1.1.2 && npm ci && npm run build'sudo -u boardplanner sh -c 'umask 077; cat > /opt/board-planner/app/.env' <<EOFMONGODB_URI=mongodb://127.0.0.1:27017/boardplannerPUBLIC_ORIGIN=https://board.example.comAPP_ORIGIN=https://board.example.comTRUSTED_PROXY_HOPS=1ENCRYPTION_KEY=$(openssl rand -hex 32)EOFThen /etc/systemd/system/board-planner.service:
[Unit]Description=Board PlannerAfter=network-online.targetWants=network-online.target
[Service]Type=simpleUser=boardplannerWorkingDirectory=/opt/board-planner/appExecStart=/opt/board-planner/app/node_modules/.bin/next start -H 127.0.0.1 -p 3000Restart=on-failureRestartSec=5NoNewPrivileges=truePrivateTmp=trueProtectSystem=fullProtectHome=true
[Install]WantedBy=multi-user.targetsudo systemctl daemon-reloadsudo systemctl enable --now board-plannersudo journalctl -u board-planner | grep "setup code"The unit starts Next.js directly, bound to the loopback interface, and finds node on systemd’s own PATH, which covers /usr/local/bin and /usr/bin. The last four lines keep the service from gaining privileges, give it its own /tmp, make /usr, /boot and /etc read-only to it and hide /home; the checkout in /opt, including .next/cache, stays writable. The setup code can take a few seconds to appear; sudo journalctl -u board-planner -f follows the log.
Keep it running with pm2
Section titled “Keep it running with pm2”From the checkout, as the user that owns it:
npm install -g pm2pm2 start node_modules/.bin/next --name board-planner -- start -H 127.0.0.1 -p 3000pm2 savepm2 startuppm2 logs board-planner --lines 200 --nostream | grep "setup code"npm install -g needs sudo when Node.js is installed system-wide. pm2 runs Next.js directly, bound to the loopback interface, for the reason above. Its log keeps every start, and each start prints a new code; the last one is the one that works. pm2 startup prints one command to run with sudo; it installs the service that brings pm2 back after a reboot. On Linux that service fails its first start while the pm2 you started by hand is still running, so either reboot, or run pm2 kill and then sudo systemctl start pm2-$USER once.
From a clone, with Docker
Section titled “From a clone, with Docker”The compose file in a clone builds that checkout — but only when you say so:
docker compose up -d --buildThe MCP server for stdio clients builds separately, and only those clients need it — the HTTP endpoint at /api/mcp is part of the app:
cd mcp-server && npm ci && npm run buildOn a hosting platform
Section titled “On a hosting platform”Anywhere that runs a container and can reach a MongoDB will do, and the steps are the same as
running the image: deploy ghcr.io/rafalpodles/board-planner:1.1.2,
give it MONGODB_URI, ENCRYPTION_KEY, and PUBLIC_ORIGIN and APP_ORIGIN set to the address the
platform serves, then read the setup code out of the service’s logs. Keep the database on the
platform’s private network, and name the database in the connection string — a mongodb://…:27017
with no path puts everything in test, which matters when you back it up.
Such a platform terminates HTTPS and puts its own proxy in front of the app. Unless you know how
many entries that proxy appends to X-Forwarded-For, leave TRUSTED_PROXY_HOPS at 0: a number too
high counts an entry the caller wrote, so every guess brings its own address, and a number too low
but above 0 counts an address of the proxy’s, so everyone behind it shares one bucket at the tight
per-address limit. At 0 everyone shares a bucket too, but at twenty times that limit.
Read the number out of the log rather than guessing it. From 1.1.2 the app warns when a request that throttles by address arrives carrying the header while this is 0; in releases after 1.1.2 that warning also says how many entries the header held, and that count is the value to set:
A request arrived with X-Forwarded-For carrying 2 entries while TRUSTED_PROXY_HOPS=0, so theheader is ignored and the login throttle has no per-address key. …Deploy with the variable unset and attempt a sign-in at the address your users use — a wrong
password is enough. Loading the page logs nothing: the header is read where a request is throttled
by address, which on the login route is after a username and password have both arrived. Then read
the count from the platform’s log, set TRUSTED_PROXY_HOPS to it and redeploy, and the warning
stops.
Expect more than one entry when a CDN sits in front of a platform that also proxies, because each appends one of its own. How many is what the log answers — counting the services you pay for is not counting the hops, which is the whole reason to measure.
The count is the value only where every proxy appends. One that replaces the header hides
everything in front of it — Caddy does exactly that unless its trusted_proxies names the
proxy ahead of it — so behind a CDN the log would say 1, and setting 1 would key the throttle on
the CDN’s edge address rather than the visitor’s. Make each proxy append first, then measure.
Read the count off an attempt you made through the whole chain: a caller can send the header too, and the platform’s own health checks reach the app by a shorter path and carry fewer entries. That is why the app reports each new count rather than only the first — after four distinct counts, a further new one at most once every ten minutes. That bound can be held open against you: the app cannot tell your sign-in from a forged header, so a caller sending a fresh header length every few minutes keeps the slot taken. Redeploying or restarting frees the first four, so do that and attempt the sign-in straight afterwards — enough against slow forged traffic, but a caller sending a few requests a second retakes the slots at once. Then the reliable path is to stop the exposure: block the source, or read the count from a staging deployment or a local run behind the same chain. A header carrying no address at all is never reported and costs none of the four. See Configuration.
MongoDB
Section titled “MongoDB”- The compose file runs
mongo:4.4for you, with its data in themongo-datavolume and no port published on the host. - A container of your own, on a Docker network the app shares:
docker network create board-planner, thendocker run -d --name mongo --network board-planner --restart unless-stopped -v mongo-data:/data/db mongo:4.4, as in Run the image against your own MongoDB. - A server with MongoDB Community Edition from MongoDB’s own packages — any version from 4.4 on.
- A hosted database such as MongoDB Atlas: its
mongodb+srv://string is theMONGODB_URI. Put the database name in the path,…mongodb.net/boardplanner?…, or the data lands intest.
Whichever it is, keep it off the public internet: only the app needs to reach it.
Backups
Section titled “Backups”mongodump writes the database to one compressed file and mongorestore puts it back. The file holds everything on the board, so the backup commands create it readable by you alone, and the app is stopped while a restore replaces what it is reading. With the compose file, from its directory:
(umask 077; docker compose exec -T mongo mongodump --archive --gzip --db boardplanner > boardplanner-$(date +%F).archive.gz)docker compose stop appdocker compose exec -T mongo mongorestore --archive --gzip --drop < boardplanner-2026-09-22.archive.gzdocker compose start appWith MongoDB in a container on the board-planner network:
(umask 077; docker run --rm --network board-planner mongo:4.4 mongodump --uri mongodb://mongo:27017/boardplanner --archive --gzip > boardplanner-$(date +%F).archive.gz)docker stop board-plannerdocker run --rm -i --network board-planner mongo:4.4 mongorestore --uri mongodb://mongo:27017 --archive --gzip --drop < boardplanner-2026-09-22.archive.gzdocker start board-plannerWith MongoDB on the machine itself, as on the from-source path, the same two tools with --network host and mongodb://127.0.0.1:27017/boardplanner, and systemctl stop / start (or pm2 stop / start) around the restore — or run mongodump and mongorestore directly if MongoDB’s Database Tools are installed. --drop replaces each collection with the one in the file. Keep ENCRYPTION_KEY apart from the backups but as safe: a dump holds integration tokens and chat webhook URLs encrypted with it, and without the key they cannot be read back.
Configuration
Section titled “Configuration”Everything is environment variables; Configuration has the full table. These decide whether an instance you run for other people works, and whether it is safe:
| Variable | Set it to |
|---|---|
PUBLIC_ORIGIN |
The address people open, https://board.example.com. Every link the app sends starts with it, and the MCP endpoint, its /.well-known documents and enrolling a machine answer 500 without it. |
APP_ORIGIN |
The same address. It lists the origins allowed to make changes. |
TRUSTED_PROXY_HOPS |
1 behind one Caddy or nginx, 0 with nothing in front. 2 with a CDN in front of the proxy — for nginx as it is below, for Caddy only with trusted_proxies. The login throttle keys on it. |
ENCRYPTION_KEY |
openssl rand -hex 32, generated once and kept. Without it no integration token or chat webhook can be saved. |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM |
Your mail server, for notification mail and for resetting a forgotten password by email. |
BOOTSTRAP_TOKEN |
Only when you cannot read the app’s log, or it runs more than one replica — see The first account. |
The running app reads all of them, so a change is a restart and never a rebuild.
HTTPS in front of it
Section titled “HTTPS in front of it”The app speaks plain HTTP. Anything used by anyone but you goes behind a reverse proxy that terminates TLS, on the same machine, with the app published on 127.0.0.1 only. The app’s side of that is the same for every proxy:
PUBLIC_ORIGIN=https://board.example.comAPP_ORIGIN=https://board.example.comTRUSTED_PROXY_HOPS=1With the compose file, those three go in .env together with APP_PORT=127.0.0.1:3000, then docker compose up -d. The https:// PUBLIC_ORIGIN is what gives the session cookie Secure and the __Host- prefix: from 1.1.2 the compose file passes COOKIE_ALLOW_INSECURE=auto, which issues the plain cookie only while every one of the instance’s own addresses is http://, and never to a sign-in that came over https://. A compose file up to 1.1.1 passed 1 instead — with one of those, add COOKIE_ALLOW_INSECURE=0 as well, or signing in over HTTPS fails. Anywhere else, leave COOKIE_ALLOW_INSECURE out.
Caddy gets and renews a certificate by itself once board.example.com resolves to the machine and ports 80 and 443 reach it. /etc/caddy/Caddyfile:
board.example.com { reverse_proxy 127.0.0.1:3000}Then sudo systemctl reload caddy. Caddy sends the client’s address in X-Forwarded-For, which is the one hop TRUSTED_PROXY_HOPS=1 describes.
Caddy replaces an incoming X-Forwarded-For rather than appending to it, unless the request comes from a proxy it was told to trust. So behind a CDN it passes on one entry, the CDN’s address, whatever TRUSTED_PROXY_HOPS says. To keep the chain, name the CDN’s address ranges at the top of the Caddyfile and set TRUSTED_PROXY_HOPS=2:
{ servers { trusted_proxies static 203.0.113.0/24 198.51.100.0/24 }}The option needs a recent Caddy: 2.11 accepts it, and the 2.6.2 that Debian 13 packages refuses the Caddyfile.
With a certificate from certbot, or from anywhere else, /etc/nginx/conf.d/board-planner.conf:
server { listen 443 ssl; server_name board.example.com;
ssl_certificate /etc/letsencrypt/live/board.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/board.example.com/privkey.pem;
client_max_body_size 6m;
location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_read_timeout 300s; }}Then sudo nginx -t && sudo systemctl reload nginx. client_max_body_size 6m lets through an attachment of up to 5 MB, the app’s own limit, and a machine’s larger reports; nginx’s default of 1 MB refuses a 2 MB image with its own 413 Request Entity Too Large. proxy_buffering off lets the MCP endpoint’s event stream through as it is written. $proxy_add_x_forwarded_for appends the client’s address to whatever arrived: one hop, or two with a CDN in front.
The app prints the hop count it settled on when it starts — TRUSTED_PROXY_HOPS=1 — the client address is taken 1 entries from the right of X-Forwarded-For. Then sign in at https://board.example.com.
The first account
Section titled “The first account”The instance needs one administrator before anyone can sign in. Once that account exists, every other account is created from Settings → Users — there is no open sign-up to leave switched on by accident.
On the sign-in page, choose First time? Create Account. It asks for a setup code, so an instance that is reachable before you register cannot be claimed by whoever finds it first:
- Unset
BOOTSTRAP_TOKEN— the app prints a code to its log when it starts with no accounts: No account exists yet. To create the first administrator, open /login and enter this setup code: …. It is held in memory, so a restart prints a new one, and each replica prints its own. - Set
BOOTSTRAP_TOKEN(16 characters or more) — that value is the code. Use it when the log is out of reach or the app runs on more than one replica. A shorter value is ignored with a warning in the log, and the app generates a code as if it were unset.
| Running it with | Read the code with |
|---|---|
| Compose | docker compose logs app | grep "setup code" |
docker run |
docker logs board-planner 2>&1 | grep "setup code" |
| systemd | sudo journalctl -u board-planner | grep "setup code" |
| pm2 | pm2 logs board-planner --lines 200 --nostream | grep "setup code" |
| A hosting platform | The service’s logs |
A wrong code is refused, and repeated wrong codes from one source are throttled. A generated code gets through a throttled source; a BOOTSTRAP_TOKEN does not, because a value you chose may be guessable — wait out the 15 minutes, or remove BOOTSTRAP_TOKEN and use the code the app prints instead.
Connecting a machine
Section titled “Connecting a machine”A machine is where an agent runs: a checkout of your repository, a worker that claims tasks from the board, and the claude and gh it drives. All it needs from the server is its address, so it is set up the same whichever path the server took. Execution workers explains each piece; this is the order to do them in.
On the board, first. Name the project’s repository under Project settings → Integrations, and turn on Let workers run tasks for this project under Project settings → Workers. First agent run walks through it.
On the machine: git, Node.js, npm, claude signed in with claude auth login, and gh signed in with gh auth login. The worker checks each one when it starts and the fleet console shows what it found — see What the machine needs. Two things it does not decide for you:
- Who the commits are by. From 1.1.2, with a GitHub account pinned (below), commits carry that account’s name and noreply address, or a
nameandemailyou add beside the pin ingithub.json. With nothing pinned — and on any worker up to 1.1.1 — they carry the name and address git has for the checkout: set them there,git -C ~/code/the-repo config user.name "…"andgit -C ~/code/the-repo config user.email "…", or globally with--global. See Who the worker is. - Which GitHub account pushes, when
ghholds more than one. Pin one, or pushes follow whichever accountgh auth switchlast chose — see Which GitHub account it pushes as.
A Mac, with the menubar app
Section titled “A Mac, with the menubar app”The short path, and the one to take on a Mac. Download board-planner-menubar-X.Y.Z.zip and SHA256SUMS from the latest release and check the download:
shasum -a 256 -c SHA256SUMS --ignore-missingUnzip it, move CPMenubar.app to Applications and open it. It is signed and notarised, so macOS asks once and opens it. Its panel asks for the board’s address and a folder for its checkouts, checks the machine, and Connect opens a browser for you to approve it. The app clones the repository and starts the worker itself — see From the app.
A Mac by hand, under launchd
Section titled “A Mac by hand, under launchd”The worker on its own, from the release tarball, unpacked in a folder of its own in your home:
mkdir -p ~/board-planner-worker && cd ~/board-planner-workerVERSION=1.1.2curl -fsSLO https://github.com/rafalpodles/board-planner/releases/download/v$VERSION/board-planner-worker-$VERSION.tar.gzcurl -fsSLO https://github.com/rafalpodles/board-planner/releases/download/v$VERSION/SHA256SUMSshasum -a 256 -c SHA256SUMS --ignore-missingtar -xzf board-planner-worker-$VERSION.tar.gzcd workernode dist/main.js --preflightKeep it out of Downloads, Documents and Desktop: macOS asks before a background process reads those folders, and a worker started by launchd has nobody to ask. The same goes for its checkouts: keep them out of those folders too.
An instance administrator mints an enrolment token under Settings → Workers → Enrol a worker, signed in through the browser. It is shown once, lasts an hour and registers one machine. Copy it, write it where only you can read it, and list the checkout the worker may use — ~/code/the-repo stands for your clone of the repository the project names. Keep checkouts in your home directory: the worker refuses one under /tmp, /private/tmp, /etc, /private/var or /System, under ~/Library, ~/.ssh, ~/.config or ~/.claude, or with a node_modules folder in its path.
mkdir -p -m 700 ~/.boardplannerinstall -m 600 /dev/null ~/.boardplanner/token && pbpaste > ~/.boardplanner/tokenprintf '{ "repos": ["%s"] }\n' "$HOME/code/the-repo" > ~/.boardplanner/repos.jsonchmod 600 ~/.boardplanner/repos.jsonThen install the launchd plist, still in worker/:
sed -e "s|REPO_DIR|$(cd .. && pwd)|g" -e "s|HOME_DIR|$HOME|g" \ launchd/com.boardplanner.worker.plist > ~/Library/LaunchAgents/com.boardplanner.worker.plistOpen ~/Library/LaunchAgents/com.boardplanner.worker.plist and set CP_API_URL to your board’s address and CP_WORKER_NAME to a name for this machine; its program path assumes node is at /opt/homebrew/bin/node. Then load it:
launchctl unload ~/Library/LaunchAgents/com.boardplanner.worker.plist 2>/dev/nulllaunchctl load ~/Library/LaunchAgents/com.boardplanner.worker.plistThe worker registers, deletes the token file, and appears under Settings → Workers with its Preflight column filled in. It writes to two logs: /tmp/boardplanner-worker.log for its progress, and /tmp/boardplanner-worker.error.log for everything it warns or complains about — which is where most of the messages in Troubleshooting turn up. By hand has the detail.
A Linux machine registers and reports, and by default runs nothing. The worker confines the agent to its worktree with macOS’s sandbox, and has no equivalent on Linux. A Linux worker’s sandbox check fails and it logs not claiming any work: it heartbeats, answers Pause and Stop, and claims no task. To run agents there you accept that the agent can write anywhere its user can, with CP_ALLOW_UNCONFINED_AGENT=1 — read Running the agent unconfined first, and use a machine that holds nothing else, under a user that owns nothing else.
The tarball, the token and repos.json are as on a Mac, with sha256sum -c SHA256SUMS --ignore-missing for the checksum and an editor rather than pbpaste for the token. For a dedicated user, say builder, with the worker unpacked in ~/board-planner-worker, /etc/systemd/system/board-planner-worker.service:
[Unit]Description=Board Planner workerAfter=network-online.targetWants=network-online.target
[Service]Type=simpleUser=builderWorkingDirectory=/home/builder/board-planner-worker/workerEnvironment=CP_API_URL=https://board.example.comEnvironment=CP_WORKER_NAME=linux-box-1Environment=CP_ENROLMENT_TOKEN_FILE=/home/builder/.boardplanner/tokenEnvironment=PATH=/home/builder/.local/bin:/usr/local/bin:/usr/bin:/binExecStart=/usr/bin/env node dist/main.jsRestart=on-failureRestartSec=30NoNewPrivileges=truePrivateTmp=trueProtectSystem=fullProtectHome=tmpfsBindPaths=/home/builder
[Install]WantedBy=multi-user.targetsudo systemctl daemon-reloadsudo systemctl enable --now board-planner-workersudo journalctl -u board-planner-worker -fPATH must name wherever claude, gh, git and node are for that user; systemd’s own does not include ~/.local/bin. ProtectHome=tmpfs with BindPaths=/home/builder shows the worker its own home and nobody else’s, where its checkouts, its state and the claude and gh sign-ins live. To accept the risk, add the variable in a drop-in rather than in the unit, so it stays a separate decision anyone can see:
sudo systemctl edit board-planner-worker[Service]Environment=CP_ALLOW_UNCONFINED_AGENT=1sudo systemctl restart board-planner-workerAfter the restart the fleet console marks the machine ⚠ sandbox, with a line under it that begins Warning.
Handing it a task
Section titled “Handing it a task”With a machine connected and the project switched on, a task runs when it names an agent, was assigned to the machine’s owner by that owner, and sits in the approved column. First agent run takes it from there.
The MCP server is part of the app, at /api/mcp under PUBLIC_ORIGIN. Create a token under Settings → API Tokens and check that the endpoint answers:
curl -s -X POST https://board.example.com/api/mcp \ -H "Authorization: Bearer cp_..." \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'It answers with the list of tools. Connecting Claude Code or another client, and the OAuth connector that needs no pasted token, are in Claude Code and MCP. A 500 that names PUBLIC_ORIGIN is the endpoint saying it does not know its own address.
Upgrading
Section titled “Upgrading”Take a backup first. Then, by path:
| Path | Upgrade with |
|---|---|
Compose, following latest |
docker compose pull && docker compose up -d |
| Compose, pinned | Change BOARD_PLANNER_VERSION in .env, then the same two commands |
docker run |
docker pull the new tag, docker rm -f board-planner, and the same docker run with the new tag. The database and board-planner.env are outside the container, so nothing is lost. |
| systemd | Below |
| pm2 | As below, with pm2 stop board-planner and pm2 start board-planner in place of systemctl |
| A hosting platform | Change the image tag on the service, which redeploys it |
| The menubar app | Quit and stop the worker in its panel, replace CPMenubar.app in Applications, open it again |
| A worker run by hand | Unpack the newer tarball over the old one, then unload and load the plist, or sudo systemctl restart board-planner-worker |
From source, stop the app before npm ci replaces the dependencies it is running from. vX.Y.Z is the release you are moving to:
sudo systemctl stop board-plannersudo -u boardplanner -H bash -c 'cd /opt/board-planner/app && git fetch --tags && git checkout vX.Y.Z && npm ci && npm run build'sudo systemctl start board-plannerA machine keeps its credential, checkouts and settings in ~/.boardplanner, outside the software, so upgrading one never means enrolling it again. A few releases ask for one more step:
Upgrading from NEXT_PUBLIC_APP_URL
Section titled “Upgrading from NEXT_PUBLIC_APP_URL”Earlier releases built links from NEXT_PUBLIC_APP_URL, which Next.js bakes into the bundle when the app is built. The app no longer reads it: rename it to PUBLIC_ORIGIN in your .env or on your host. The compose file still passes an old NEXT_PUBLIC_APP_URL on as PUBLIC_ORIGIN when PUBLIC_ORIGIN is unset, so an untouched compose setup keeps its links — but that fallback exists in the compose file only. Railway, docker run and anything else need PUBLIC_ORIGIN itself.
Upgrading an instance with a pm account
Section titled “Upgrading an instance with a pm account”The PM agent acts as an account named pm, which is a machine account: it has no password and cannot sign in. If your instance has a pm account stored as a person — somebody created one by hand — the app converts it on its next start, signs it out everywhere and revokes its tokens, connected apps and machines, and logs a warning naming its role. A person who used that account needs an account of their own.
Upgrading an instance that already had team channels
Section titled “Upgrading an instance that already had team channels”Before this release, a project’s Slack or Discord channel URL was stored as plain text. New and edited channels are now encrypted; the rest are rewritten by a one-off sweep:
MONGODB_URI=... ENCRYPTION_KEY=... npx tsx scripts/migrate-channel-webhooks.ts --dry-runMONGODB_URI=... ENCRYPTION_KEY=... npx tsx scripts/migrate-channel-webhooks.tsOn a hosting platform, run it wherever the database is reachable from, with the same two variables in front.
--dry-run lists what it would touch and changes nothing. It is safe to re-run.
The run ends with up to two lists. Anything under act on these is a channel whose URL is still plain text — a row with no id, or one that somebody edited while the sweep was running. Anything under had nothing to encrypt is a note and needs no action. The job is done when a second --dry-run reports nothing left to encrypt.
The sweep stops the current record from carrying the URL. It does not undo the exposure: those URLs have been written to every backup taken since the channel was created, so revoke them in Slack or Discord and paste the new ones in if that matters to you.
Troubleshooting
Section titled “Troubleshooting”| What you see | Why | What to do |
|---|---|---|
docker compose up stops at services.app.env_file.0 must be a string |
Compose older than 2.24, which cannot read the file’s optional env_file entry |
Update Docker Compose; docker compose version must say 2.24 or later |
error from registry: denied pulling the image |
The package is private, or the name is misspelled — the registry says the same for both | Check the name against ghcr.io/rafalpodles/board-planner, which is public |
…board-planner:1.0.1: not found |
That release has no image; images start at 1.1.0 | Pin 1.1.0 or later, or leave latest |
docker compose up -d in a clone runs something other than your checkout |
It pulls and runs the last release; --build is what builds the checkout |
docker compose up -d --build. And after a --build, docker compose pull puts the release back behind latest |
No setup code in the log, and MongoDB is unreachable … ECONNREFUSED 127.0.0.1:27017 |
MONGODB_URI says localhost inside a container, which is the container itself |
Name the database’s real host — see Run the image against your own MongoDB |
/api/mcp, both /.well-known documents and enrolling a machine answer 500 — This instance’s own origin is not configured |
No PUBLIC_ORIGIN, and APP_ORIGIN names more than one origin or none |
Set PUBLIC_ORIGIN to the address people open, and restart |
| The log says COOKIE_ALLOW_INSECURE=1 requires every APP_ORIGIN (or PUBLIC_ORIGIN) to be an http:// origin — from 1.1.2 the app will not start; up to 1.1.1 signing in over HTTPS failed with that line | COOKIE_ALLOW_INSECURE=1 on an instance served over https://. A compose file up to 1.1.1 supplies that 1 itself, whether the line in .env is missing or empty |
With a 1.1.2 image, take the 1.1.2 docker-compose.yml, whose default is auto; with any image, COOKIE_ALLOW_INSECURE=0 in .env. Then docker compose up -d |
EADDRINUSE … 0.0.0.0:3000 from npm start up to 1.1.1, with a different PORT in .env |
That release’s npm start read PORT before the app read .env |
Give the port on the command line — node_modules/.bin/next start -H 127.0.0.1 -p …, as above — or update: from 1.1.2 npm start reads it from .env |
| The worker logs …token is readable by group or others (mode 644); run chmod 600 on it | The enrolment token file is readable by others, so the worker will not use it. The token is not spent | chmod 600 the file, then restart the worker. A 1.0.1 worker says only no identity on disk and no CP_ENROLMENT_TOKEN for the same thing |
| A launchd worker from the 1.0.1 tarball logs no identity on disk and no CP_ENROLMENT_TOKEN although the token file is there | That release’s plist names the key CP_API_TOKEN_FILE, which the worker no longer reads |
Rename it to CP_ENROLMENT_TOKEN_FILE in the plist, then unload and load it — or use a newer tarball, whose plist is right |
| The worker logs not claiming any work: set CP_ALLOW_UNCONFINED_AGENT=1… | The machine is not a Mac, so there is no sandbox to confine the agent | Expected. See Linux before accepting the risk |
| A worker up to 1.1.1 logs local control socket unavailable: … listen EINVAL | CP_STATE_DIR is too long a path for the socket inside it: macOS allows 104 bytes for the whole path. From 1.1.2 the worker moves the socket under /tmp instead, and the menubar app finds it there |
Update the worker and the app, or use a shorter state directory. The worker runs without the socket, but the menubar app cannot talk to it |
| A task says Your machine is connected but not taking work for this board: its checkout is in /private/tmp…, and Machines offering this repository says the machine cannot use its checkout | The checkout is in a directory the worker refuses to run in. Up to 1.1.1 the machine read live instead, and only the Binding error column in Settings → Workers said so | Move the checkout into your home directory, outside the ones listed above, and update repos.json. The worker picks the change up on its next refresh |
| The pull request is by the right GitHub account, but its commits carry somebody else’s name | Up to 1.1.1, commits used the name and address git has for the checkout, and gh decided only who pushes. From 1.1.2 a worker commits as the account pinned in github.json |
Update the worker, or on 1.1.1 set git config user.name and user.email in the checkout before the next run. The worker’s preflight names the identity it will commit as |
| A machine is connected but the project’s Machines offering this repository stays empty | The worker has not reported a checkout of that repository: repos.json is missing, or the checkout’s origin is a different URL. It re-reads repos.json on every refresh, within a minute of a change — a worker up to 1.1.1 only while the board answers its refresh, so a machine the board was refusing kept the list it last read |
Check repos.json, the checkout’s git remote -v and the machine’s Binding error column in Settings → Workers. With a worker up to 1.1.1, restart it if a minute passes and the console still shows the old list |
Troubleshooting covers what goes wrong once the instance is running.
After it is up
Section titled “After it is up”- Create your first project and adjust its columns — see Board columns and task fields.
- Add the rest of the team and grant project access — see Members and permissions.
- Connect a machine, or issue an API token for an agent working the board over MCP — see Connecting a machine and Claude Code and MCP.