SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-13

Self-host Planka for VPS with Docker Compose

Deploy Planka for VPS with Docker Compose, Postgres, and Traefik. Set the admin bootstrap variables and BASE_URL correctly so login no go fail.

Wetin self-hosting Planka go give you

Self-hosting Planka go give your team Kanban board wey get the card, list, and label model wey people already know from Trello, and e go run 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, use Postgres for the data and named volume for every file wey people upload.

The reader wey I get for mind na team of two to five people wey dey comot 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 choose already, and e cover only the deploy.

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

How much VPS Planka need?

The project no publish any minimum hardware, so treat any number wey you read as starting point, no be confirmed measurement. 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 things wey actually dey run small: one Node.js process wey dey serve the API and built frontend, plus one Postgres process wey dey hold the data. Another small proxy process dey run inside the Planka container to filter requests wey e dey send out. Plan wey get 1 vCPU and 2 GB fit carry board wey two to five people dey use, and most spare memory go end up as Postgres cache.

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 go print live memory and CPU for each container. The second one go show how much space each volume dey hold. Take both readings after normal working week, no be on install day, because idle board no dey tell you anything about your team.

Compose file schrijf

Maak de directory en neem ownership van am, so you no go need edit these files through sudo.

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

Generate the secrets inside one .env file beside the Compose file. Compose dey 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 hold only digits and letters from a to f, so e no fit spoil 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 you fit waste one hour. The wider pattern dey explained for how to keep secrets outside 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 deserve explanation, because na dem people dey change and later regret.

  • No ports: block dey for the Planka service. Traefik dey reach the container through the proxy network, so port 1337 no dey published on the host. If you publish am, anybody fit bypass your proxy and certificate.
  • loadbalancer.server.port=1337 dey specify 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 go start before the database accept connections, fail its first query, then exit. That one fit look like crash loop. The details dey for Compose healthchecks and startup ordering.
  • The database service name na postgres deliberately. Planka 2 dey route 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 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 two ways to fix 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 together with am. This one dey happen for first boot against empty database, so these variables dey bootstrap account, dem no dey manage am.

DEFAULT_ADMIN_EMAIL dey do another work wey dey catch people off guard. As long as you leave the variable set, nobody fit edit or delete the account wey e name from the interface. Na lock-out guard be this, and na why you no fit rename the account or change the email address for 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: fit readable by anybody wey fit run docker inspect for the container, 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 way na to skip the variables completely. 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 e go write the user directly to database. The password no go touch Compose file or container environment. Use this way 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.

Either way, you go still manage Planka passwords by hand. If na the fourth set of credentials wey 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 no dey work when e no match the hostname

BASE_URL na the exact address wey people dey type for browser, together with scheme and without trailing slash. For this stack, na https://kanban.example.com. Planka dey build im 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 with your real domain. Login form go submit and e go accept your credentials. But board no go show. Open browser developer console, and you go see requests to /socket.io/ dey fail. Client receive instruction to open live connection to localhost:3000, but for your laptop that address no be anything.

TRUST_PROXY=true na the other side 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 X-Forwarded-Proto and X-Forwarded-For headers wey Traefik set. So e believe say connection no secure, and e treat every client as one shared IP address. When you set am, app go read those headers and scheme go agree with wetin browser dey use.

Traefik dey proxy WebSockets without extra configuration. Na one reason why e good to use am here. For nginx, socket.io need im own location block wey carry proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade". If not, you go get the same spinner wey dey stuck, but from another cause.

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

Where 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 dey exist again, while the real data directory remain unmounted.

That one mount na the difference between board wey survive upgrade and serious wahala. If /app/data no dey for volume, uploads go enter the container writable layer. The system dey destroy that layer when e recreate the container, and e recreate the container every time you change the image tag. The board go come back looking fine, all the cards go still dey there, but every attachment link go dead because the database rows still point to files wey no dey again.

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

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

The difference between both options 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 machine. Make this decision before the team fill the board, because the setting dey apply to new uploads only.

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 dey run database migrations, then e report say server dey listen on port 1337. Confirm say schema really enter 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 say 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 say Traefik get certificate and e dey reach the container. 404 wey Traefik serve mean say router labels no match, most time because container no dey attached to the proxy network. Now open the site and log in with the admin account.

Take pg_dump before every version bump

Two separate storage places dey hold your board, so backup must 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 during restore. The failure fit show weeks later, for the worst possible time.

Then handle the uploads. First find the real volume name, because Compose dey add the 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 ship docker-backup.sh and docker-restore.sh for its repository, and the official documentation put dem for nightly cron job. Either method dey okay. But backup wey you never restore no be okay. Restore one to scratch VPS once, then confirm say you fit log in and open attachment.

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 the tags and read the release notes

Both image tags for that file dey pinned on purpose.

ghcr.io/plankanban/planka:2.1.1 na one specific release, as of August 2026. latest dey change anytime upstream publish new release, so routine docker compose pull fit bring schema migration at time wey you no choose. Read the 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 this kind thing wey you suppose read, instead make you absorb am by accident.

postgres:16-alpine dey pinned to major version for stronger reason. Postgres dey write im data directory for format wey depend on major version, and server no go open directory wey different version write. Write postgres:latest, allow the tag roll to 17, and the 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 version mean say you dump data from the old version, then restore am into fresh data directory for the new one. Na planned job be that, with the stack down; e no be side effect of image pull.

If you dey move existing Planka 1.x install instead of starting fresh, that upgrade get im own documented procedure for project documentation, and no way dey to return to version 1 without backup wey you take before time.

Wetin fit fail and the strings wey you go see

Planka dey restart for loop and log mention database. The credentials for DATABASE_URL no match the Postgres environment. Remember say POSTGRES_PASSWORD only apply when e first initialise the data directory, so if you fix the variable after the first boot fail, nothing go change. You must remove the 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 no dey. Browser console go show requests to /socket.io/ wey fail.

Uploads dey fail but every other thing dey work. Na bind mount wey root own. 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 the 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 for proxy network, or Host() rule no match your DNS record. docker compose config go show the labels after substitution; na there typo dey show.

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 get blocked by design. Adjust OUTGOING_ALLOWED_HOSTS instead of removing the filter.

Once e dey 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 for boot, and Compose stacks wey come back after reboot cover the cases where e no happen.

FAQ

Planka login ke dey load forever?

Credentials don accept, but live connection no connect. Planka dey build its WebSocket URL from BASE_URL. So, if that variable still dey show http://localhost:3000 while you dey access the site through https://kanban.example.com, browser go try open socket to address wey no dey 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 X-Forwarded-Proto header from your reverse proxy. Then run docker compose up -d.

How I fit create the first Planka admin user?

From version 1.13, no administrator dey created automatically. You fit either set DEFAULT_ADMIN_EMAIL together 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 edited or deleted from the interface.

Where Planka dey store attachments and avatars?

For Planka 2, all uploaded files dey under /app/data inside the container. This one include attachments, user avatars, and board backgrounds. Mount that path on a named volume. If the path no mount, the files go stay for the container writable layer. The files go disappear the next time you recreate the container, and this one dey happen for every image upgrade. 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 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 full workload. So, 1 vCPU and 2 GB plan fit carry team of two to five. Run docker stats --no-stream after normal week, then size based on your own numbers. Monitor 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. Leave the Postgres tag pinned to its major version, because the server no go open data directory wey another major version write.