SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

Self-host Planka: open-source Kanban board

Deploy Planka on a VPS with Docker Compose: Postgres, Traefik, the admin bootstrap variables, and the BASE_URL setting that breaks your logins.

What you get by self-hosting Planka

Self-hosting Planka gives your team a Kanban board with the card, list and label model people already know from Trello, running on a VPS you control. There are no seat limits and no per-user billing, because the only cost is the server. This guide deploys it with Docker Compose behind Traefik, using Postgres for the data and a named volume for every file people upload.

The reader I have in mind is a team of two to five leaving Trello's free tier. If you are still deciding which board to run, read the comparison of self-hosted Trello alternatives first. This guide assumes the choice is already made and covers only the deploy.

You need a VPS running Docker Engine with the Compose plugin, and a DNS A record pointing at it. You also need a Traefik instance already terminating TLS (transport layer security) on that box. If Traefik is not there yet, set up a Traefik reverse proxy in front of several Compose apps first, and read the Docker Compose basics for a VPS if the file below looks unfamiliar.

How much VPS does Planka need?

The project does not publish a hardware floor, so treat any number you read as a starting point rather than a measurement. The 2 vCPU and 4 GB figure that hosting pages repeat is a provider's comfortable default, not a requirement the project measured. It is generous for a board that five people touch.

What actually runs is small: one Node.js process serving the API and the built frontend, and one Postgres process holding the data. A third small proxy process runs inside the Planka container to filter its outgoing requests. A 1 vCPU and 2 GB plan carries a two-to-five person board, and most of the spare memory ends up as Postgres cache.

Size the disk before you size the memory, because attachments are the part that grows. Measure your own instance instead of trusting this paragraph:

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

The first prints live memory and CPU per container. The second shows how much space each volume holds. Take both readings after a normal working week, not on install day, because an idle board tells you nothing about your team.

Write the Compose file

Create the directory and take ownership of it, so you never have to edit these files through sudo.

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

Generate the secrets into a .env file beside the Compose file. Compose reads that file automatically and substitutes 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 is deliberate. A hex string holds only digits and the letters a to f, so it cannot break the DATABASE_URL connection string it gets pasted into. A base64 password with a slash or an at sign in it produces a connection error that reads like a wrong hostname, and that costs you an hour. The wider pattern is covered in keeping secrets out of the Compose file.

Now docker-compose.yml. Replace kanban.example.com with your own hostname in both places it appears.

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 in that file are worth explaining, because they are the ones people change and then regret.

  • There is no ports: block on the Planka service. Traefik reaches the container across the proxy network, so port 1337 is never published on the host. Publishing it would give anyone a way around your proxy and your certificate.
  • loadbalancer.server.port=1337 names the port inside the container. Planka listens on 1337, and the upstream example only reaches it on 3000 because it maps the port to the host. There is no host mapping here, so Traefik has to be told the container port.
  • condition: service_healthy pairs with the Postgres healthcheck. Without it Planka starts before the database accepts connections, fails its first query and exits, which looks like a crash loop. The mechanics are in Compose healthchecks and startup ordering.
  • The database service is named postgres on purpose. Planka 2 routes its own outgoing requests through an internal filter whose default block list is localhost,postgres. Rename the service and you quietly remove your database from that list.

Check that Compose can see your secrets before you start anything:

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

That prints the file with .env values already substituted. An empty value means Compose is not reading the .env file, usually because you are running the command from a different directory.

What the admin bootstrap variables actually do

Since Planka 1.13 no administrator is created for you, so a fresh database has nobody who can log in. The DEFAULT_ADMIN_* group is one of the two ways to fix that.

On startup Planka looks for a user matching DEFAULT_ADMIN_EMAIL. If there is none, it creates one using the password, display name and username set alongside it. That happens on the first boot against an empty database, so these variables bootstrap an account rather than manage it.

DEFAULT_ADMIN_EMAIL does a second job that catches people out. While the variable stays set, the account it names cannot be edited or deleted from the interface by anyone. That is a lock-out guard, and it is also why you cannot rename that account or change its email address in the UI. Remove the variable and restart, and the account becomes an ordinary admin you can edit like any other.

The password line is the one to be careful with. Anything under environment: is readable by anyone who can run docker inspect on the container, so DEFAULT_ADMIN_PASSWORD should not live there permanently. Log in, change your password in the interface, delete that line, then run docker compose up -d again.

The cleaner route skips the variables entirely. Comment out the whole DEFAULT_ADMIN_* group, then create the account interactively:

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

It prompts for email, password, display name and an optional username, and writes the user straight to the database. The password never touches the Compose file or the container environment. Use this route if more than one person has shell access to the VPS. The command starts Postgres first because of depends_on, so it works on a stack that has never been up.

Why BASE_URL breaks logins when it does not match the hostname

BASE_URL is the exact address people type into the browser, with the scheme and without a trailing slash. For this stack that is https://kanban.example.com. Planka builds its own links and its WebSocket connection from that value, which means a wrong BASE_URL does not give you a clean error. It gives you a page that loads and then never finishes loading.

The common version: you copy the upstream example, leave BASE_URL=http://localhost:3000 in place, and reach the site over HTTPS on your real domain. The login form submits and your credentials are accepted. The board never appears. Open the browser developer console and you will see requests to /socket.io/ failing, because the client was told to open its live connection to localhost:3000, and on your laptop that address is nothing at all.

TRUST_PROXY=true is the other half of the same problem. Planka sits behind Traefik, so every request reaches it from the proxy's address over plain HTTP inside the Docker network. Without TRUST_PROXY, the app ignores the X-Forwarded-Proto and X-Forwarded-For headers that Traefik sets, so it believes the connection is insecure and treats every client as one shared IP address. With it set, the app reads those headers and agrees with the browser about the scheme.

Traefik proxies WebSockets with no extra configuration, which is one reason to prefer it here. On nginx, socket.io needs its own location block carrying proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade", or you get the same stuck spinner from a different cause.

Moving the board to a new hostname later means changing two things together: the BASE_URL value and the Traefik Host() rule. Change one and forget the other, and you are back at the spinner. Serving Planka from a subpath such as https://example.com/planka works from version 2.1.0 onward, released March 2026. On older tags, give it a subdomain of its own.

Where Planka keeps attachments and avatars

Planka 2 stores everything a user uploads under a single path inside the container: /app/data. Attachments, user avatars and board background images all live under it. Version 1 used three separate directories, so a Compose file copied from an older write-up mounts paths that no longer exist, and the real data directory is left unmounted.

That single mount is the difference between a board that survives an upgrade and a bad afternoon. If /app/data is not on a volume, uploads land in the container's writable layer. That layer is destroyed when the container is recreated, and the container is recreated every time you change the image tag. The board comes back looking fine, the cards are all there, and every attachment link is dead, because the database rows still point at files that no longer exist.

The named volume in the Compose file above prevents this. A bind mount works too and makes the files easier to back up with ordinary tools, but it needs one extra step. The Node process inside the container runs as UID 1000, so a host directory owned by root gives a permission error on the first upload:

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

The trade-off between the two is worked through in bind mounts against named volumes.

If attachments outgrow the disk on your plan, Planka can write them to S3-compatible storage instead, through S3_ENDPOINT, S3_BUCKET and the matching key variables. That can point at a hosted bucket or at a self-hosted MinIO object store on another box. Decide this before the team fills the board, because the setting applies to new uploads.

Start the stack and check it worked

docker compose pull
docker compose up -d
docker compose ps

docker compose ps should show postgres as healthy and planka as running. If Planka is restarting in a loop, the database connection is the first thing to check, not the app.

docker compose logs -f planka

A healthy first boot runs the database migrations and then reports the server listening on port 1337. Confirm the schema really landed by asking Postgres directly instead of trusting the log:

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

A table list that includes board and card means the migrations ran. "Did not find any relations" means Planka never connected, so compare DATABASE_URL against the POSTGRES_USER and POSTGRES_PASSWORD values in your .env.

Then check the route from your own machine, not from the VPS:

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

HTTP/2 200 means Traefik holds a certificate and is reaching the container. A 404 served by Traefik means the router labels did not match, most often because the container is not attached to the proxy network. Now open the site and log in with the admin account.

Take a pg_dump before every version bump

Two separate stores hold your board, so a backup has to cover both: the Postgres database and the planka-data volume. Dump the database while the stack is running.

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

The -T is not optional. Without it Compose allocates a pseudo-terminal, and the terminal layer rewrites line endings in the stream, so you get a dump file that fails partway through a restore. The failure appears weeks later, which is the worst possible time.

Then the uploads. Find the real volume name first, because Compose prefixes it with the project directory name.

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 also ships docker-backup.sh and docker-restore.sh in its repository, and the official documentation puts them on a nightly cron job. Either approach is fine. What is not fine is a backup you have never restored, so restore one onto a scratch VPS once and confirm you can log in and open an attachment.

Run the dump immediately before every version change. A backup from last night is not the same thing as a backup from before the migration you are about to run.

Pin the tags and read the release notes

Both image tags in that file are pinned on purpose.

ghcr.io/plankanban/planka:2.1.1 is a specific release, current as of August 2026. latest moves whenever upstream publishes, so a routine docker compose pull can bring in a schema migration at a moment you did not choose. Read the release notes before you change that number, because that is where breaking changes and security fixes are described. Version 2.0.3 was published as a security release, which is exactly the kind of thing you want to read rather than absorb by accident.

postgres:16-alpine is pinned to a major version for a harder reason. Postgres writes its data directory in a format tied to the major version, and the server refuses to open a directory written by a different one. Write postgres:latest, let the tag roll to 17, and the container will not 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 is lost, and nothing is fixed by restarting either. Moving to a new Postgres major means a dump from the old version and a restore into a fresh data directory on the new one. That is a planned job with the stack down, not a side effect of an image pull.

If you are moving an existing Planka 1.x install rather than starting fresh, that upgrade has its own documented procedure in the project documentation, and there is no way back to version 1 without a backup taken beforehand.

Failure modes and the strings you will see

Planka restarts in a loop and the log names the database. The credentials in DATABASE_URL do not match the Postgres environment. Note that POSTGRES_PASSWORD is only applied when the data directory is first initialised, so fixing the variable after a bad first boot changes nothing. You have to remove the db-data volume and start again.

Login succeeds and the board never loads. BASE_URL does not match the address in the browser bar, or TRUST_PROXY is missing. The browser console shows failing requests to /socket.io/.

Uploads fail while everything else works. A bind mount owned by root. Run sudo chown -R 1000:1000 on the host directory and restart the container.

Attachments disappeared after an upgrade. /app/data was not on a volume, so the files sat in the container layer that the upgrade replaced. Restore the files from a backup, then add the volume before you touch the image tag again.

Traefik returns 404. The container is not on the proxy network, or the Host() rule does not match your DNS record. docker compose config shows the labels after substitution, which is where typos become visible.

Notifications or webhooks never arrive. Planka 2 sends its outgoing HTTP requests through an internal filter, and the default block list covers localhost and postgres. A webhook aimed at another container on the same host can be blocked by design. Adjust OUTGOING_ALLOWED_HOSTS rather than removing the filter.

Once it is running the operational load is small. Watch the release notes, and dump the database before every upgrade. A reboot brings the stack back on its own because of restart: unless-stopped, as long as the Docker service itself is enabled at boot, and Compose stacks that come back after a reboot covers the cases where it does not.

FAQ

Why does Planka load forever after I log in?

The credentials were accepted and the live connection was not. Planka builds its WebSocket URL from BASE_URL, so if that variable still says http://localhost:3000 while you reach the site at https://kanban.example.com, the browser tries to open a socket to an address that does not exist on your machine. The developer console shows failing requests to /socket.io/. Set BASE_URL to the exact public address with no trailing slash, add TRUST_PROXY=true so the app honours the X-Forwarded-Proto header from your reverse proxy, then run docker compose up -d.

How do I create the first Planka admin user?

Since version 1.13 no administrator is created automatically. Either set DEFAULT_ADMIN_EMAIL with its matching password, name and username variables and start the stack, or run docker compose run --rm planka npm run db:create-admin-user and answer the prompts. The interactive command is the safer one on a shared server, because the password never enters the container environment where docker inspect can read it. Keeping DEFAULT_ADMIN_EMAIL set afterwards locks that account against edits and deletion from the interface.

Where does Planka store attachments and avatars?

All uploaded files live under /app/data inside the container in Planka 2, including attachments, user avatars and board backgrounds. Mount that path on a named volume. If it is unmounted, the files sit in the container's writable layer and are destroyed the next time the container is recreated, which happens on every image upgrade. A bind mount works too, but the Node process runs as UID 1000, so run sudo chown -R 1000:1000 on the host directory or uploads fail with a permission error.

How much RAM does a self-hosted Planka need?

The project publishes no hardware floor. The 2 vCPU and 4 GB figure repeated on hosting pages is a provider's default rather than a measurement, and it is generous for a small board. One Node process and one Postgres process is the whole workload, so a 1 vCPU and 2 GB plan carries a team of two to five. Run docker stats --no-stream after a normal week and size from your own numbers. Watch the disk more closely than the memory, because attachments are what grows.

How do I upgrade Planka without losing data?

Dump the database and archive the uploads volume immediately before the upgrade, not on last night's schedule. Use docker compose exec -T postgres pg_dump -U planka -d planka > planka-db.sql, keeping the -T so the pseudo-terminal does not corrupt the redirected output. Read the release notes for every version you skip, change the image tag to a specific release rather than latest, then run docker compose pull and docker compose up -d and watch the log for the migration. Leave the Postgres tag pinned to its major version, because the server refuses to open a data directory written by a different major.