Use case · Kanban boards

PLANKA self hosted: the official Compose stack behind nginx

A self-hosted PLANKA is two containers from the project's own docker-compose.yml: the PLANKA application and postgres:16-alpine, with every upload in one Docker volume. Set BASE_URL and a new SECRET_KEY, create the first admin with a command-line script while the app answers on 127.0.0.1 alone, then publish it through an HTTPS proxy that carries socket.io websockets. The Community edition is free to self-host under PLANKA's Fair Use License, which is source-available, not open source in the OSI sense.

See the machines

What the official Compose file runs

The installation guide downloads docker-compose.yml from the repository's master branch into /opt/planka: two services, two named volumes. The third column is our configuration, not the project's defaults.

ItemAs shippedSet it to
planka imageghcr.io/plankanban/planka:latest, restart: on-failureUnchanged
ports3000:1337127.0.0.1:3000:1337 (our change)
BASE_URLhttp://localhost:3000https://board.example.com, exactly as browsers reach it
SECRET_KEYnotsecretkeyThe output of openssl rand -hex 64 (the Compose comment says -hex 32; either is long enough)
TRUST_PROXYCommented outtrue, because nginx sits in front
DEFAULT_ADMIN_*Commented outLeave them; the script creates the admin
postgrespostgres:16-alpine, POSTGRES_HOST_AUTH_METHOD=trust, no published portUnchanged; never publish its port
volumesdata at /app/data, db-data for PostgresBoth belong in the backup

The comment above SECRET_KEY in the Compose file is blunt: every access token is signed with it, so keeping the published example value lets anyone mint a token for any account, and PLANKA warns about it at every start until it is changed. The database container trusts every connection on the Compose network and has no password, which is acceptable only while its port stays unpublished.

The docs' proxy pages do not explain TRUST_PROXY. In server/config/env/production.js it sets the framework's trustProxy, which the comments there say makes the app read X-Forwarded headers: the client's real IP and the HTTPS scheme for secure cookies.

Sizing the machine

The PLANKA docs publish no CPU, memory or disk minimum, so this page quotes none. Our reading: a Node.js process and one Postgres database are a light load, and disk follows the uploaded files, not the board count. Leave room for the two weeks or so of local backups the job below keeps.

On our dedicated server range that points at the small end: a single-socket rack server, or a Moonshot cartridge if you accept that an out-of-band console on cartridges is limited to the m510 and m710x, with power control alone on the rest. Every racked single-, dual- and quad-socket server has a full console (HPE iLO, Dell iDRAC or a Supermicro BMC), which is what gets you back in after a firewall mistake. Leave the X-Gene ARM cartridge aside: the only systems on its list are Ubuntu 18.04 and the older 16.04.

One tenant per machine, root for that tenant, an unmetered port, and our own AS19624 network across five cities. From payment approval, a racked machine reaches you in about 30 minutes; built-to-order lines need 4 to 24 hours. IPv6 is not in any default build; it is a free checkout option, and the AAAA record needs it. Docker on bare metal covers several apps on one box.

nginx with socket.io websockets

Live board updates use socket.io, so websocket upgrades must pass. The nginx sample in the PLANKA docs routes any path matching \.io to a location with the Upgrade and Connection headers, 50 MB uploads and one-day timeouts. Ours is simplified from that sample, and every difference is our change: the upstream is 127.0.0.1:3000 (the sample's 1337 is the manual-install port), proxy_http_version 1.1 is added to the socket location, client_max_body_size moves up to the server block, and the log, buffer, cache, X-Frame-Options and extra timeout lines are dropped. The file goes in /etc/nginx/sites-available/planka:

upstream planka {
    server 127.0.0.1:3000;
    keepalive 32;
}
server {
    listen 80;
    listen [::]:80;
    server_name board.example.com;
    client_max_body_size 50M;

    location ~* \.io {
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 1d;
        proxy_send_timeout 1d;
        proxy_pass http://planka;
    }
    location / {
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://planka;
    }
}

Install order: DNS, certificate, then PLANKA

  1. Create the A record for board.example.com (plus AAAA when the machine has IPv6) and check that it resolves before going on; certbot depends on it.
  2. Install Docker Engine, at least version 28, together with the Compose plugin, from Docker's apt repository per its install guide for your distribution.
  3. As root, run the block below in order: the firewall is up before any service listens, with only SSH and the web ports open.
  4. At the first nano of the Compose file, set the four lines from the table: the 127.0.0.1:3000:1337 mapping, BASE_URL, SECRET_KEY and TRUST_PROXY=true. stat should print 600 before you paste the key in.
  5. The admin script asks for Email:, Password:, Name: and Username (optional):. In server/db/create-admin-user.js a password that scores too low on the zxcvbn check is refused, and an email that already belongs to an account brings up that account and Update this account? [y/N]; yes replaces its password, name and username, makes it an admin and reactivates it if it was deactivated.
  6. docker compose up -d starts both services. Browse to the BASE_URL and sign in with the email and password you just set.
# 1. firewall: IPv6 rules on, SSH kept, then the web ports
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
ufw allow 80,443/tcp

# 2. proxy and certificate (the DNS record must already resolve)
apt update && apt install -y nginx certbot python3-certbot-nginx
nano /etc/nginx/sites-available/planka
ln -s /etc/nginx/sites-available/planka /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
certbot --nginx -d board.example.com -m you@example.com --agree-tos --no-eff-email --redirect

# 3. compose file, private before any secret goes in
install -d -m 700 /opt/planka
install -m 600 /dev/null /opt/planka/docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/plankanban/planka/master/docker-compose.yml -o /opt/planka/docker-compose.yml
stat -c %a /opt/planka/docker-compose.yml
openssl rand -hex 64
nano /opt/planka/docker-compose.yml

# 4. first admin, then start
cd /opt/planka
docker compose run --rm planka npm run db:create-admin-user
docker compose up -d

A port mapping with no address, like the shipped 3000:1337, is published on every host address, 0.0.0.0 and [::] both. Published traffic skips ufw's rules on IPv4, and on IPv6 too when Docker's own IPv6 networking is enabled; otherwise the [::] listener is docker-proxy, and ufw's rules do apply to it. An address of 127.0.0.1 in the mapping makes the port loopback-only on Docker Engine 28 and newer (moby#45610). nginx runs on the host as a normal process, so it needs the ufw rule for 80 and 443 on IPv4 and IPv6 alike.

BASE_URL must be the address people actually type. A comment in server/config/env/production.js calls a mismatch the commonest way a self-hosted instance fails: the page loads, then every request behind it is refused, with nothing said about which two values disagreed. The installation page also recommends listing internal services in OUTGOING_BLOCKED_HOSTS; by default only localhost and postgres are blocked.

Accounts, admins and SSO

Since version 1.13 PLANKA creates no administrator by itself. The docs give two routes: the db:create-admin-user script used above, or the DEFAULT_ADMIN_EMAIL, DEFAULT_ADMIN_PASSWORD, DEFAULT_ADMIN_NAME and DEFAULT_ADMIN_USERNAME variables. The variables can be removed after the first successful start; if DEFAULT_ADMIN_EMAIL stays, that admin cannot be edited or deleted by others. We use the script, which keeps the password out of the Compose file.

The docs describe no public sign-up page. In server/api/controllers/users/create.js, the create-user route is documented as requiring admin privileges, so new people get their accounts from an admin. Single sign-on through OIDC is listed on the docs' Community-or-Pro page as a Pro feature, not a Community one; so are guest roles.

Backups: database and data volume

The docs' backup script exports the database plus user avatars, background images and attachments, all of which version 2 keeps in the data volume. Its header warns that an unencrypted archive holds password hashes, active sessions, TOTP secrets and SMTP credentials. The script below is ours, adapted for cron: exec -T instead of the official docker exec -t, pipefail, a custom-format dump that pg_restore can read back, a copy of the Compose file (it holds SECRET_KEY), and a check of both artefacts. The volume is planka_data when the project folder is /opt/planka; docker volume ls confirms it.

# /root/planka-backup.sh -- run as root by cron
set -euo pipefail
umask 077
D=/var/backups/planka/$(date +%F)
C="docker compose -f /opt/planka/docker-compose.yml"
mkdir -p "$D"
$C exec -T postgres pg_dump -U postgres -Fc planka > "$D/planka.dump"
docker run --rm -v planka_data:/data:ro -v "$D":/out alpine tar -czf /out/data.tgz -C /data .
cp /opt/planka/docker-compose.yml "$D/"
tar -tzf "$D/data.tgz" > /dev/null
$C exec -T postgres pg_restore --list < "$D/planka.dump" > /dev/null
find /var/backups/planka -mindepth 1 -maxdepth 1 -mtime +14 -exec rm -rf {} +
rsync -a /var/backups/planka/ backup@offsite.example.net:planka/
  1. Create the root key the push needs, or cron hangs at a password prompt: ssh-keygen -t ed25519 as root, then ssh-copy-id backup@offsite.example.net. Install rsync on both ends.
  2. Create the log folder first: mkdir -m 700 /var/log/planka-backup.
  3. Put one line in /etc/cron.d/planka-backup: 30 2 * * * root umask 077; bash /root/planka-backup.sh > /var/log/planka-backup/run-$(date +\%F).txt 2>&1.

Copying the archives to another machine is your job: nothing on this server protects them against its own disk failing. The push never deletes anything at the far end; prune the off-site copies with a job of their own on that machine, so a problem on this server can never wipe them.

Restoring over a live instance

The docs warn that a restore overwrites the running instance. Set C="docker compose -f /opt/planka/docker-compose.yml" and D=/var/backups/planka/2026-10-20 (the day you want), then:

  1. Check both parts of the backup: tar -tzf $D/data.tgz > /dev/null && cat $D/planka.dump | $C exec -T postgres pg_restore --list > /dev/null && echo ok.
  2. Stop the app, not the database: $C stop planka.
  3. Dump the current database under its own name and check it: $C exec -T postgres pg_dump -U postgres -Fc planka > /var/backups/planka/pre-restore.dump && cat /var/backups/planka/pre-restore.dump | $C exec -T postgres pg_restore --list > /dev/null && echo ok.
  4. Set the current files aside and check them: docker run --rm -v planka_data:/data:ro alpine tar -czf - -C /data . > /var/backups/planka/pre-restore-data.tgz && tar -tzf /var/backups/planka/pre-restore-data.tgz > /dev/null && echo ok. Stop here unless both checks printed ok.
  5. Load the database: $C exec -T postgres pg_restore -U postgres -d planka --clean --if-exists < $D/planka.dump.
  6. Replace the files and give them back to uid 1000, the user the image is built for: docker run --rm -v planka_data:/data -v $D:/in:ro alpine sh -c 'find /data -mindepth 1 -delete && tar -xzf /in/data.tgz -C /data && chown -R 1000:1000 /data'.
  7. $C start planka, sign in, open a card with an attachment. Only then delete the two pre-restore files. If anything is wrong, stop the app again and repeat steps 5 and 6 with pre-restore.dump and pre-restore-data.tgz in place of the backup files.

On a new server, put the saved Compose file in /opt/planka at mode 600 first, run $C create so the containers and volumes exist, then $C start postgres, wait until $C ps reports it healthy, and carry on with steps 5 to 7.

Updates and the v1 to v2 upgrade

The docs' update procedure is three commands after a backup: cd /opt/planka && docker compose down, docker compose pull, docker compose up -d. The image tag is latest, so a pull moves you to the newest release.

  • Version 1 installs must reach 1.26.2 first; the docs say there is no way back without a proper backup.
  • v2 adds the single data volume; files from the old avatar, background-image and attachment volumes are copied into it, and npm run db:upgrade must run before PLANKA starts for the first time.
  • TRUST_PROXY takes true or false; the numeric 0 and 1 are no longer supported. ALLOW_ALL_TO_CREATE_PROJECTS, the SLACK_*, GOOGLE_* and TELEGRAM_* variables and TZ are removed.
  • An additional step restores due-date completion states from the v1 SQL dump; skipped, those cards come back as not completed.

Licence terms

Read this as our plain-language take, not legal advice: LICENSE.md and the License Guide decide, and license@planka.group answers edge cases. The repository's LICENSE.md is the PLANKA Community License, version 1.1, last updated May 20, 2025. Files outside the Pro/Enterprise exceptions are under its Fair Use License; files with .pe. in their name or marked PLANKA Pro/Enterprise need a commercial licence. The v1.26.2 tag still carries an AGPL-3.0 licence file; the change came with the version 2 commit.

  • Permitted: personal, hobby and educational use; use internally within your own organization; private hosting for a typical number of friends, family or personal projects; free access for non-profits; academic use by public educational institutions.
  • Prohibited without a commercial licence: sharing accounts with third parties for business purposes, or running PLANKA as a hosted service for third parties for any commercial gain. The License Guide's own example says giving board logins to a client you have a commercial relationship with is not allowed.
  • Kept obligations: do not remove licensing or copyright notices, pass these terms on with any copy, and mark modified copies prominently.

Questions

Does a self-hosted PLANKA cost anything?

The Community edition is free to self-host under the Fair Use License, for personal use and for internal use within your own organization. You pay for the machine it runs on. Pro is a separate commercial licence.

Can our clients log in to our PLANKA boards?

Not under the Fair Use License if you have a commercial relationship with them. The License Guide uses exactly that case as an example of what needs a PLANKA Pro/Enterprise licence.

Which ports have to be open?

SSH, and ports 80 and 443, which nginx and certbot use. PLANKA's 3000 stays on 127.0.0.1 and Postgres publishes no port at all.

Does the Community edition do OIDC single sign-on?

No. The docs list SSO through OIDC, along with guest roles, as Pro features.

Put the board on a machine you control

A whole server with root, an unmetered port, and a full out-of-band console on every racked single-, dual- and quad-socket line.

See the machines

Related on Server Room

Other use cases