SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-29

How to Self-Host Planka with Docker Compose

Deploy Planka for your team with Docker Compose, Postgres, and Traefik. See the admin bootstrap variables and why a wrong BASE_URL fit break your logins.

Wetin self-hosting Planka give you

Self-hosting Planka give your team one Kanban board with the card, list, and label model wey people already know from Trello, running for VPS wey you control. No seat limit or per-user billing dey, because na only the server cost you go pay. This guide deploy am with Docker Compose behind Traefik, using Postgres for the data and one named volume for every file wey people upload.

The reader wey I get for mind na team of two to five people wey dey move from Trello free tier. If you still dey decide which board to run, read the comparison of self-hosted Trello alternatives first. This guide assume say you don already make the choice, and e cover only the deploy.

You need one VPS wey dey run Docker Engine with the Compose plugin, plus one DNS A record wey point to am. You also need one Traefik instance wey already dey terminate TLS (transport layer security) for that box. If Traefik never dey there, first set up one Traefik reverse proxy in front of several Compose apps, and read Docker Compose basics for a VPS if the file below no familiar to you.

How much VPS Planka need?

The project no publish any fixed hardware minimum, so treat any number wey you read as starting point, no be measured requirement. The 2 vCPU and 4 GB figure wey hosting pages dey repeat na provider comfortable default, no be requirement wey the project measure. E plenty for board wey five people dey use.

The actual workload small: one Node.js process dey serve the API and the built frontend, while one Postgres process dey hold the data. One other small proxy process dey run inside the Planka container to filter its outgoing requests. Plan wey get 1 vCPU and 2 GB fit carry board for two to five people, and most spare memory go end up as Postgres cache. Board no dey use plenty resources, so if the same VPS suppose also hold your team documents, size am based on that app first: running AFFiNE as Notion-style workspace need couple of gigabytes by itself before Planka need anything.

Size the disk before you size the memory, because attachments na the part wey dey grow. Measure your own instance instead of trusting this paragraph:

docker stats --no-stream
docker system df -v

The first one dey print live memory and CPU for each container. The second one show how much space each volume get. Take both readings after normal working week, no be on install day, because idle board no fit tell you anything about your team.

Write the Compose file

Make the directory and take ownership of am, so you no go ever need edit these files through sudo.

sudo mkdir -p /opt/planka
sudo chown "$USER":"$USER" /opt/planka
cd /opt/planka

Generate the secrets inside a .env file beside the Compose file. Compose go read that file automatically and substitute the values.

umask 077
{
  printf 'SECRET_KEY=%s\n' "$(openssl rand -hex 64)"
  printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 24)"
  printf 'ADMIN_PASSWORD=%s\n' "$(openssl rand -hex 12)"
} > .env
chmod 600 .env

openssl rand -hex na deliberate choice. Hex string dey contain only digits and letters from a to f, so e no fit break the DATABASE_URL connection string wey e enter. Base64 password wey get slash or at sign fit cause connection error wey go look like wrong hostname, and that fit waste one hour. The wider pattern dey covered for how to keep secrets out of the Compose file.

Now docker-compose.yml. Replace kanban.example.com with your own hostname for both places wey e appear.

services:
  planka:
    image: ghcr.io/plankanban/planka:2.1.1
    restart: unless-stopped
    volumes:
      - planka-data:/app/data
    environment:
      - BASE_URL=https://kanban.example.com
      - DATABASE_URL=postgresql://planka:${POSTGRES_PASSWORD}@postgres/planka
      - SECRET_KEY=${SECRET_KEY}
      - TRUST_PROXY=true
      - DEFAULT_ADMIN_EMAIL=you@example.com
      - DEFAULT_ADMIN_PASSWORD=${ADMIN_PASSWORD}
      - DEFAULT_ADMIN_NAME=Your Name
      - DEFAULT_ADMIN_USERNAME=admin
    networks:
      - proxy
      - internal
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.planka.rule=Host(`kanban.example.com`)"
      - "traefik.http.routers.planka.entrypoints=websecure"
      - "traefik.http.routers.planka.tls.certresolver=default"
      - "traefik.http.services.planka.loadbalancer.server.port=1337"
    depends_on:
      postgres:
        condition: service_healthy

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=planka
      - POSTGRES_USER=planka
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    networks:
      - internal
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U planka -d planka"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  planka-data:
  db-data:

networks:
  proxy:
    external: true
  internal:

Four decisions for that file need explanation, because na dem people dey change and later regret.

  • No ports: block dey for the Planka service. Traefik reaches the container through the proxy network, so port 1337 no dey published for the host. If you publish am, anybody fit bypass your proxy and certificate.
  • loadbalancer.server.port=1337 dey name the port inside the container. Planka dey listen on 1337, and the upstream example only reaches am on 3000 because e maps the port to the host. No host mapping dey here, so you must tell Traefik the container port.
  • condition: service_healthy dey work together with the Postgres healthcheck. Without am, Planka starts before the database accepts connections, e fail the first query and exit, and that go look like crash loop. The details dey for Compose healthchecks and startup ordering.
  • The database service name na postgres on purpose. Planka 2 routes its own outgoing requests through an internal filter whose default block list na localhost,postgres. If you rename the service, you quietly remove your database from that list.

Check say Compose fit see your secrets before you start anything:

docker compose config | grep -E 'image:|BASE_URL|POSTGRES_USER'

That command go print the file with .env values already substituted. Empty value mean say Compose no dey read the .env file, usually because you dey run the command from another directory.

Wetin the admin bootstrap variables really dey do

Since Planka 1.13, no administrator dey created for you, so fresh database no get anybody wey fit log in. The DEFAULT_ADMIN_* group na one of the two ways to solve am.

When e start, Planka dey look for user wey match DEFAULT_ADMIN_EMAIL. If e no find any, e go create one with the password, display name, and username wey you set beside am. This one dey happen for the first boot against empty database, so these variables dey bootstrap an account, not manage am.

DEFAULT_ADMIN_EMAIL get another job wey dey confuse people. As long as the variable remain set, nobody fit edit or delete the account wey e name from the interface. This na lock-out guard, and na why you no fit rename that account or change the email address for the UI. Remove the variable and restart, then the account go become normal admin wey you fit edit like any other one.

Na the password line you need handle carefully. Anything under environment: anybody wey fit run docker inspect for the container fit read am, so DEFAULT_ADMIN_PASSWORD no suppose remain there permanently. Log in, change your password for the interface, delete that line, then run docker compose up -d again.

The cleaner method no use the variables at all. Comment out the whole DEFAULT_ADMIN_* group, then create the account interactively:

docker compose run --rm planka npm run db:create-admin-user

E go ask for email, password, display name, and optional username, then write the user directly to the database. The password no go enter the Compose file or the container environment. Use this method if more than one person get shell access to the VPS. The command go start Postgres first because of depends_on, so e go work for stack wey never come up before.

Any of the two methods still mean say you go manage Planka passwords by hand. If na the fourth set of credentials your team don collect, Planka fit instead delegate logins to OIDC provider like Authentik wey dey run as your own single sign-on server, while you keep the bootstrap admin as break-glass account for the day wey the provider no dey available.

Why BASE_URL dey break login when e no match hostname

BASE_URL na the exact address wey people dey type for browser, with the scheme and without trailing slash. For this stack, na https://kanban.example.com. Planka dey build its own links and WebSocket connection from that value. So, wrong BASE_URL no dey give clean error. E go load page, then e no go ever finish loading.

The common case be say: you copy upstream example, leave BASE_URL=http://localhost:3000 as e be, then access the site over HTTPS for your real domain. Login form go submit and e go accept your credentials. But board no go show. Open browser developer console. You go see requests to /socket.io/ dey fail because client dey expect to open its live connection to localhost:3000, and that address no dey exist at all for your laptop.

TRUST_PROXY=true na the other part of the same problem. Planka dey behind Traefik, so every request dey reach am from proxy address over plain HTTP inside Docker network. Without TRUST_PROXY, app no dey use the X-Forwarded-Proto and X-Forwarded-For headers wey Traefik set. So e believe say connection no secure, and e dey treat every client as one shared IP address. When you set am, app go read those headers and scheme go match wetin browser dey use.

Traefik dey proxy WebSockets without extra configuration. Na one reason to prefer am here. For nginx, socket.io need its own location block wey carry proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade". Otherwise, you go get the same spinner wey dey stuck, but this time na different cause.

If you move the board to new hostname later, change two things together: the BASE_URL value and the Traefik Host() rule. If you change one and forget the other, spinner go come back. Serving Planka from subpath like https://example.com/planka dey work from version 2.1.0 onward, released March 2026. For older tags, give am its own subdomain.

Wey Planka dey keep attachments and avatars

Planka 2 dey store everything wey user upload under one path inside the container: /app/data. Attachments, user avatars and board background images all dey there. Version 1 use three different directories, so Compose file wey person copy from older write-up go mount paths wey no longer exist, and the real data directory go remain unmounted.

That single mount na the difference between board wey survive upgrade and bad afternoon. If /app/data no dey on volume, uploads go enter the container writable layer. Dem go destroy that layer when dem recreate the container, and dem go recreate the container every time you change the image tag. The board go come back looking fine, all the cards go still dey there, and every attachment link go dead, because the database rows still point to files wey no longer exist.

The named volume for the Compose file above dey prevent this. Bind mount work too and make the files easier to back up with ordinary tools, but e need one extra step. The Node process inside the container dey run as UID 1000, so host directory wey root own go give permission error for the first upload:

sudo chown -R 1000:1000 /opt/planka/data

The trade-off between the two dey explained for bind mounts against named volumes.

If attachments pass the disk space for your plan, Planka fit write dem to S3-compatible storage instead, through S3_ENDPOINT, S3_BUCKET and the matching key variables. That one fit point to hosted bucket or self-hosted MinIO object store for another box. Decide this before the team fill the board, because the setting dey apply to new uploads.

Start the stack and check say e work

docker compose pull
docker compose up -d
docker compose ps

docker compose ps suppose show postgres as healthy and planka as running. If Planka dey restart for loop, database connection na the first thing to check, no be the app.

docker compose logs -f planka

Healthy first boot go run database migrations, then report say server dey listen on port 1337. Confirm say schema really land by asking Postgres directly, instead make you trust the log:

docker compose exec postgres psql -U planka -d planka -c '\dt'

Table list wey include board and card mean say migrations run. "Did not find any relations" mean Planka never connect, so compare DATABASE_URL with the POSTGRES_USER and POSTGRES_PASSWORD values for your .env.

Then check the route from your own machine, no be from the VPS:

curl -I https://kanban.example.com

HTTP/2 200 mean Traefik get certificate and e dey reach the container. 404 wey Traefik serve mean the router labels no match, most times because the container no attach to the proxy network. Now open the site and log in with the admin account.

Take pg_dump before every version bump

Two separate stores dey hold your board, so backup suppose cover both: the Postgres database and the planka-data volume. Dump the database while the stack dey run.

docker compose exec -T postgres pg_dump -U planka -d planka > "planka-db-$(date +%F).sql"

The -T no be optional. Without am, Compose go allocate pseudo-terminal, and the terminal layer go rewrite line endings for the stream. This one fit make dump file fail halfway through restore. The failure fit show weeks later, for the worst possible time.

Then handle the uploads. Find the real volume name first, because Compose dey add project directory name as prefix.

docker volume ls | grep planka
docker run --rm -v planka_planka-data:/data -v "$PWD":/backup alpine \
  tar czf /backup/planka-files-$(date +%F).tgz -C /data .

The project still get docker-backup.sh and docker-restore.sh for its repository, and the official documentation recommend say make dem run for nightly cron job. Either method dey okay. But backup wey you never restore no be valid backup. Restore one to scratch VPS once, then confirm say you fit log in and open attachment. This same pair of stores dey show for every Compose app wey accepts uploads. So if you later put Chatwoot for the same box with your support desk, the routine wey you build here go still work, with only the volume names changing.

Run the dump immediately before every version change. Backup from last night no be the same as backup wey you take before the migration wey you wan run.

Pin tags and read release notes

Dem pin both image tags for that file on purpose.

ghcr.io/plankanban/planka:2.1.1 na one specific release, current as of August 2026. latest dey move whenever upstream publish new release, so routine docker compose pull fit bring schema migration for time wey you no choose. Read release notes before you change that number, because na there dem describe breaking changes and security fixes. Dem publish version 2.0.3 as security release, and na exactly the kind thing wey you suppose read instead make you absorb am by accident. Pinning easy here because upstream dey publish images at all. Where project no publish image, you still owe am the same discipline plus one extra step, like openGym wey you build for the box from checked-out git tag.

postgres:16-alpine pin to one major version for stronger reason. Postgres dey write data directory for format wey tie to major version, and server go refuse open directory wey another version write. Write postgres:latest, allow tag roll to 17, and container no go start:

FATAL:  database files are incompatible with server
DETAIL:  The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17.

Nothing go lost, and restarting no go fix anything too. Moving to new Postgres major mean say you dump data from old version, then restore am into fresh data directory for the new one. Na planned job be that, with the stack down. E no suppose happen as side effect of image pull.

If you dey move existing Planka 1.x install instead of starting fresh, that upgrade get its own documented procedure for project documentation. You no fit go back to version 1 without backup wey you take before the upgrade.

Failure modes and the strings you will see

Planka dey restart for loop and log dey mention database. Credentials wey dey for DATABASE_URL no match Postgres environment. Remember say POSTGRES_PASSWORD only dey apply when e first initialise data directory, so if you fix the variable after bad first boot, e no go change anything. You must remove db-data volume and start again.

Login dey work but board no ever load. BASE_URL no match the address for browser bar, or TRUST_PROXY dey miss. Browser console go show requests to /socket.io/ wey dey fail.

Uploads dey fail while everything else dey work. Na bind mount wey root own am. Run sudo chown -R 1000:1000 for the host directory, then restart the container.

Attachments disappear after upgrade. /app/data no dey for volume, so the files dey inside container layer wey the upgrade replace. Restore the files from backup, then add the volume before you touch the image tag again.

Traefik dey return 404. The container no dey on proxy network, or Host() rule no match your DNS record. docker compose config dey show the labels after substitution, and na there typos dey become clear.

Notifications or webhooks no ever arrive. Planka 2 dey send outgoing HTTP requests through internal filter, and the default block list cover localhost and postgres. Webhook wey target another container for the same host fit dey blocked by design. Adjust OUTGOING_ALLOWED_HOSTS instead of removing the filter.

Once e don run, the operational work small. Monitor the release notes, and dump the database before every upgrade. Reboot go bring the stack back by itself because of restart: unless-stopped, as long as Docker service itself dey enabled at boot, and Compose stacks wey come back after reboot explain the cases where e no do so.

FAQ

Why Planka dey load forever after I log in?

The credentials don accept, but the live connection no connect. Planka dey build im WebSocket URL from BASE_URL. So if that variable still dey show http://localhost:3000 while you dey reach the site for https://kanban.example.com, the browser go try open socket to address wey no dey for your machine. Developer console go show failed requests to /socket.io/. Set BASE_URL to the exact public address without trailing slash. Add TRUST_PROXY=true so the app go respect the X-Forwarded-Proto header from your reverse proxy. Then run docker compose up -d.

How I fit create the first Planka admin user?

Since version 1.13, the system no dey create administrator automatically. You fit either set DEFAULT_ADMIN_EMAIL with the matching password, name, and username variables, then start the stack. Or run docker compose run --rm planka npm run db:create-admin-user and answer the prompts. The interactive command safer for shared server, because the password no enter the container environment where docker inspect fit read am. If you leave DEFAULT_ADMIN_EMAIL set afterwards, that account no fit get edit or delete from the interface.

Where Planka dey store attachments and avatars?

All uploaded files dey under /app/data inside the container for Planka 2. This includes attachments, user avatars, and board backgrounds. Mount that path on a named volume. If the path no mount, the files go remain for the container writable layer. The files go disappear the next time you recreate the container, and image upgrade dey cause this every time. Bind mount dey work too, but the Node process dey run as UID 1000. So run sudo chown -R 1000:1000 on the host directory, or uploads go fail with permission error.

How much RAM self-hosted Planka need?

The project no publish any minimum hardware requirement. The 2 vCPU and 4 GB figure wey hosting pages dey repeat na provider default, no be measurement. E plenty for small board. One Node process and one Postgres process na the complete workload. So 1 vCPU and 2 GB plan fit carry team of two to five. Run docker stats --no-stream after normal week and size the server from your own numbers. Monitor the disk more than memory, because attachments na wetin dey grow.

How I fit upgrade Planka without losing data?

Dump the database and archive the uploads volume immediately before the upgrade. No use last night's scheduled backup. Use docker compose exec -T postgres pg_dump -U planka -d planka > planka-db.sql, and keep the -T so the pseudo-terminal no corrupt the redirected output. Read the release notes for every version wey you skip. Change the image tag to a specific release instead of latest. Then run docker compose pull and docker compose up -d, and monitor the log for the migration. Keep the Postgres tag pinned to its major version, because the server no go open data directory wey different major version write.