Tự host Planka bằng Docker Compose trên VPS
Triển khai Planka với Docker Compose, Postgres và Traefik trên VPS. Bài viết nêu biến admin bootstrap và lỗi đăng nhập do cấu hình BASE_URL sai.
Những gì bạn nhận được khi tự host Planka
Tự host Planka cung cấp cho team một bảng Kanban với mô hình card, list và label quen thuộc với người dùng Trello, chạy trên VPS do bạn kiểm soát. Không có giới hạn số user và không tính phí theo từng user, vì chi phí duy nhất là server. Hướng dẫn này triển khai Planka bằng Docker Compose phía sau Traefik, dùng Postgres để lưu dữ liệu và một named volume cho mọi file người dùng upload.
Đối tượng của hướng dẫn này là team từ hai đến năm người đang rời khỏi gói miễn phí của Trello. Nếu bạn vẫn đang chọn board nào để chạy, hãy đọc bài so sánh các lựa chọn thay thế Trello tự host trước. Hướng dẫn này giả định bạn đã quyết định dùng Planka và chỉ bao gồm phần deploy.
Bạn cần một VPS chạy Docker Engine với Compose plugin và một DNS A record trỏ đến VPS đó. Bạn cũng cần một instance Traefik đã thực hiện TLS termination (transport layer security) trên server này. Nếu Traefik chưa có, trước tiên hãy thiết lập reverse proxy Traefik phía trước nhiều app Compose, và đọc những kiến thức cơ bản về Docker Compose trên VPS nếu file bên dưới chưa quen thuộc.
Planka cần VPS cấu hình thế nào?
Dự án không công bố cấu hình phần cứng tối thiểu, vì vậy hãy xem mọi con số bạn đọc được là điểm bắt đầu, không phải số liệu đo được. Mức 2 vCPU và 4 GB mà các trang hosting thường nhắc đến là cấu hình mặc định thoải mái của nhà cung cấp, không phải yêu cầu đã được dự án đo lường. Với một board chỉ có năm người sử dụng, mức này khá dư.
Thành phần thực sự chạy khá nhỏ: một tiến trình Node.js phục vụ API và frontend đã build, cùng một tiến trình Postgres lưu dữ liệu. Một tiến trình proxy nhỏ thứ ba chạy bên trong container Planka để lọc các request đi ra. Gói 1 vCPU và 2 GB có thể đáp ứng một board từ hai đến năm người, và phần lớn memory còn dư sẽ được Postgres dùng làm cache.
Hãy tính dung lượng disk trước khi tính memory, vì attachment là phần tăng nhanh nhất. Hãy đo trên instance của bạn thay vì chỉ dựa vào đoạn này:
docker stats --no-stream
docker system df -vLệnh đầu tiên hiển thị memory và CPU hiện tại của từng container. Lệnh thứ hai cho biết mỗi volume đang sử dụng bao nhiêu dung lượng. Hãy lấy cả hai số liệu sau một tuần làm việc bình thường, không phải ngay ngày cài đặt, vì một board không có hoạt động không cho bạn biết gì về nhóm của mình.
Tạo file Compose
Tạo thư mục và chuyển quyền sở hữu thư mục đó cho bạn, để bạn không bao giờ phải chỉnh sửa các file này thông qua sudo.
sudo mkdir -p /opt/planka
sudo chown "$USER":"$USER" /opt/planka
cd /opt/plankaTạo các secret trong file .env nằm cạnh file Compose. Compose tự động đọc file này và thay thế các giá trị tương ứng.
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 .envopenssl rand -hex là lựa chọn có chủ ý. Chuỗi hex chỉ chứa chữ số và các chữ cái từ a đến f, nên không thể làm hỏng chuỗi kết nối DATABASE_URL mà nó được chèn vào. Password base64 có dấu gạch chéo hoặc dấu @ có thể gây lỗi kết nối trông giống như hostname sai, khiến bạn mất cả giờ để tìm nguyên nhân. Mẫu xử lý rộng hơn được trình bày trong giữ secret bên ngoài file Compose.
Bây giờ chạy docker-compose.yml. Thay kanban.example.com bằng hostname của bạn ở cả hai vị trí xuất hiện.
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:Có 4 lựa chọn trong file này cần được giải thích, vì đây là những chỗ người dùng thường thay đổi rồi phải hối tiếc.
- Service Planka không có block
ports:. Traefik truy cập container qua networkproxy, nên port 1337 không bao giờ được publish trên host. Nếu publish port này, bất kỳ ai cũng có thể bỏ qua proxy và certificate của bạn. loadbalancer.server.port=1337chỉ định port bên trong container. Planka lắng nghe trên port 1337. Ví dụ upstream chỉ truy cập được port này qua port 3000 vì nó map port đó ra host. Ở đây không có mapping với host, nên phải khai báo port của container cho Traefik.condition: service_healthykết hợp với healthcheck của Postgres. Nếu thiếu cấu hình này, Planka sẽ khởi động trước khi database chấp nhận kết nối, chạy query đầu tiên thất bại rồi thoát. Hiện tượng này trông giống như crash loop. Cơ chế này được trình bày trong healthcheck và thứ tự khởi động trong Compose.- Service database được đặt tên là
postgrestheo chủ ý. Planka 2 định tuyến các request đi ra của chính nó qua một filter nội bộ có block list mặc định làlocalhost,postgres. Nếu đổi tên service, bạn sẽ âm thầm loại database khỏi block list đó.
Kiểm tra xem Compose có đọc được các secret trước khi khởi động bất kỳ thành phần nào:
docker compose config | grep -E 'image:|BASE_URL|POSTGRES_USER'Lệnh này in ra file với các giá trị .env đã được thay thế. Giá trị trống có nghĩa là Compose không đọc file .env, thường là vì bạn đang chạy lệnh từ một thư mục khác.
Các biến bootstrap admin thực sự làm gì
Từ Planka 1.13, hệ thống không còn tự tạo administrator, vì vậy database mới sẽ không có ai đăng nhập được. Nhóm DEFAULT_ADMIN_* là một trong hai cách để xử lý việc này.
Khi khởi động, Planka tìm user khớp với DEFAULT_ADMIN_EMAIL. Nếu không có, Planka sẽ tạo user bằng password, display name và username được đặt kèm theo biến này. Việc đó xảy ra trong lần boot đầu tiên với database trống, nên các biến này dùng để bootstrap một account chứ không dùng để quản lý account.
DEFAULT_ADMIN_EMAIL còn có một tác dụng khác mà nhiều người không để ý. Khi biến này vẫn được đặt, không ai có thể chỉnh sửa hoặc xóa account mà biến chỉ định từ giao diện, kể cả administrator. Đây là cơ chế bảo vệ khỏi việc tự khóa quyền truy cập. Vì vậy, bạn cũng không thể đổi tên account hoặc thay đổi địa chỉ email của account đó trong UI. Xóa biến rồi restart, account sẽ trở thành một admin thông thường và bạn có thể chỉnh sửa như các account khác.
Dòng password cần được xử lý cẩn thận. Bất kỳ giá trị nào trong environment: đều có thể được đọc bởi người có quyền chạy docker inspect trên container, vì vậy DEFAULT_ADMIN_PASSWORD không nên được giữ ở đó lâu dài. Hãy đăng nhập, đổi password trong giao diện, xóa dòng đó, rồi chạy lại docker compose up -d.
Cách gọn hơn là không dùng các biến này. Comment toàn bộ nhóm DEFAULT_ADMIN_*, rồi tạo account tương tác:
docker compose run --rm planka npm run db:create-admin-userLệnh sẽ yêu cầu nhập email, password, display name và username tùy chọn, sau đó ghi user trực tiếp vào database. Password không xuất hiện trong file Compose hoặc container environment. Hãy dùng cách này nếu có nhiều người có quyền shell trên VPS. Lệnh khởi động Postgres trước vì depends_on, nên vẫn hoạt động trên stack chưa từng được khởi động.
Cả hai cách đều yêu cầu bạn tự quản lý password của Planka. Nếu đây đã là bộ thông tin đăng nhập thứ tư mà team phải quản lý, Planka có thể chuyển việc đăng nhập cho một OIDC provider như Authentik chạy như một single sign-on server riêng, đồng thời giữ bootstrap admin làm break-glass account để sử dụng khi provider bị lỗi.
Vì sao BASE_URL làm hỏng đăng nhập khi không khớp với hostname
BASE_URL là địa chỉ chính xác mà người dùng nhập vào trình duyệt, gồm scheme và không có dấu gạch chéo ở cuối. Với stack này, đó là https://kanban.example.com. Planka dùng giá trị này để tự tạo link và kết nối WebSocket. Vì vậy, BASE_URL sai không tạo ra lỗi rõ ràng. Trang vẫn tải được nhưng không bao giờ tải xong.
Trường hợp thường gặp là bạn sao chép ví dụ upstream, giữ nguyên BASE_URL=http://localhost:3000, rồi truy cập site qua HTTPS bằng domain thật. Form đăng nhập được gửi đi và thông tin xác thực được chấp nhận. Nhưng board không xuất hiện. Mở console dành cho developer của trình duyệt, bạn sẽ thấy các request đến /socket.io/ bị lỗi. Nguyên nhân là client được yêu cầu mở kết nối thời gian thực đến localhost:3000, nhưng trên laptop của bạn địa chỉ đó hoàn toàn không tồn tại.
TRUST_PROXY=true là nửa còn lại của cùng vấn đề. Planka chạy phía sau Traefik, nên mọi request đến app từ địa chỉ của proxy qua HTTP thuần bên trong Docker network. Nếu không cấu hình TRUST_PROXY, app sẽ bỏ qua các header X-Forwarded-Proto và X-Forwarded-For do Traefik thiết lập. Vì vậy, app cho rằng kết nối không an toàn và xem mọi client như dùng chung một địa chỉ IP. Khi cấu hình giá trị này, app sẽ đọc các header đó và thống nhất scheme với trình duyệt.
Traefik proxy WebSocket mà không cần cấu hình thêm. Đây là một trong những lý do nên dùng Traefik cho trường hợp này. Với nginx, socket.io cần một block location riêng có proxy_set_header Upgrade $http_upgrade và proxy_set_header Connection "upgrade". Nếu thiếu, bạn sẽ gặp cùng một spinner bị treo nhưng do nguyên nhân khác.
Sau này, nếu chuyển board sang hostname mới, bạn phải thay đổi đồng thời 2 thứ: giá trị BASE_URL và rule Host() của Traefik. Nếu chỉ thay đổi một trong hai, spinner sẽ lại bị treo. Từ version 2.1.0, phát hành vào tháng 3 năm 2026, Planka hỗ trợ chạy dưới subpath như https://example.com/planka. Với các tag cũ hơn, hãy cấp cho Planka một subdomain riêng.
Vị trí Planka lưu tệp đính kèm và avatar
Planka 2 lưu mọi thứ người dùng tải lên trong một đường dẫn duy nhất bên trong container: /app/data. Tệp đính kèm, avatar người dùng và ảnh nền của board đều nằm trong đó. Version 1 dùng ba thư mục riêng, nên file Compose được sao chép từ một bài hướng dẫn cũ sẽ mount các đường dẫn không còn tồn tại, còn thư mục dữ liệu thực tế lại không được mount.
Mount duy nhất đó quyết định board có tồn tại sau khi upgrade hay không. Nếu /app/data không nằm trên một volume, tệp tải lên sẽ nằm trong writable layer của container. Layer đó bị xóa khi container được tạo lại, và container sẽ được tạo lại mỗi khi bạn thay đổi image tag. Board vẫn hiển thị bình thường, các card vẫn còn đó, nhưng mọi link đến tệp đính kèm đều hỏng vì các row trong database vẫn trỏ đến những tệp không còn tồn tại.
Named volume trong file Compose ở trên sẽ ngăn tình trạng này. Bind mount cũng hoạt động và giúp sao lưu tệp dễ hơn bằng các công cụ thông thường, nhưng cần thêm một bước. Node process bên trong container chạy với UID 1000, nên một thư mục trên host thuộc sở hữu của root sẽ gây lỗi permission ngay lần tải tệp đầu tiên:
sudo chown -R 1000:1000 /opt/planka/dataPhần so sánh bind mount với named volume phân tích cụ thể đánh đổi giữa hai cách này.
Nếu dung lượng tệp đính kèm vượt quá dung lượng disk trong gói của bạn, Planka có thể ghi chúng vào storage tương thích với S3 thông qua S3_ENDPOINT, S3_BUCKET và các biến key tương ứng. Bạn có thể trỏ chúng đến bucket được cung cấp bởi dịch vụ hoặc đến object store MinIO tự host trên một máy khác. Hãy quyết định trước khi team tải quá nhiều dữ liệu lên board, vì setting này chỉ áp dụng cho các tệp tải lên mới.
Khởi động stack và kiểm tra kết quả
docker compose pull
docker compose up -d
docker compose psdocker compose ps phải hiển thị postgres là healthy và planka là running. Nếu Planka khởi động lại liên tục, hãy kiểm tra kết nối database trước, không phải ứng dụng.
docker compose logs -f plankaLần boot đầu tiên thành công sẽ chạy các migration của database, sau đó báo server đang lắng nghe trên port 1337. Xác nhận schema đã thực sự được tạo bằng cách truy vấn trực tiếp Postgres thay vì chỉ dựa vào log:
docker compose exec postgres psql -U planka -d planka -c '\dt'Danh sách table có board và card cho biết các migration đã chạy. Thông báo "Did not find any relations" nghĩa là Planka chưa kết nối được, vì vậy hãy đối chiếu DATABASE_URL với các giá trị POSTGRES_USER và POSTGRES_PASSWORD trong .env của bạn.
Tiếp theo, kiểm tra route từ chính máy của bạn, không phải từ VPS:
curl -I https://kanban.example.comHTTP/2 200 nghĩa là Traefik đã có certificate và đang truy cập được container. Lỗi 404 do Traefik trả về nghĩa là các router label không khớp, thường là vì container chưa được gắn vào network proxy. Bây giờ hãy mở site và đăng nhập bằng tài khoản admin.
Thực hiện pg_dump trước mỗi lần nâng phiên bản
Board của bạn được lưu ở 2 nơi riêng biệt, vì vậy bản sao lưu phải bao gồm cả cơ sở dữ liệu Postgres và volume planka-data. Dump cơ sở dữ liệu khi stack đang chạy.
docker compose exec -T postgres pg_dump -U planka -d planka > "planka-db-$(date +%F).sql"-T là bắt buộc. Nếu không có tùy chọn này, Compose sẽ cấp phát pseudo-terminal và lớp terminal sẽ chuyển đổi ký tự xuống dòng trong luồng dữ liệu. Kết quả là file dump bị lỗi giữa chừng khi restore. Lỗi này có thể chỉ xuất hiện sau vài tuần, đúng vào thời điểm tệ nhất.
Tiếp theo là các file upload. Trước tiên hãy tìm tên volume thực tế, vì Compose thêm tiền tố là tên thư mục project.
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 .Project cũng cung cấp docker-backup.sh và docker-restore.sh trong repository, đồng thời tài liệu chính thức hướng dẫn chạy chúng bằng cron job hằng đêm. Cách nào cũng được. Điều không thể chấp nhận là có một bản sao lưu nhưng chưa bao giờ restore nó. Vì vậy, hãy restore một bản lên một VPS tạm thời, rồi xác nhận bạn có thể đăng nhập và mở một file đính kèm.
Hãy chạy dump ngay trước mỗi lần thay đổi phiên bản. Bản sao lưu của tối qua không giống bản sao lưu được tạo trước migration sắp chạy.
Ghim tag và đọc release notes
Cả hai image tag trong file đó đều được ghim có chủ đích.
ghcr.io/plankanban/planka:2.1.1 là một release cụ thể, hiện hành tại thời điểm tháng 08 năm 2026. latest thay đổi mỗi khi upstream phát hành phiên bản mới, vì vậy một lần docker compose pull thông thường có thể đưa migration schema vào đúng lúc bạn không chủ động chọn. Hãy đọc release notes trước khi thay đổi con số đó, vì breaking change và bản sửa lỗi bảo mật được mô tả ở đó. Version 2.0.3 được phát hành dưới dạng security release. Đây chính là loại thay đổi bạn nên đọc trước thay vì vô tình nhận vào.
postgres:16-alpine được ghim ở major version vì một lý do nghiêm trọng hơn. Postgres ghi data directory theo format gắn với major version, và server sẽ từ chối mở directory được ghi bởi major version khác. Ghi postgres:latest, để tag tự chuyển sang 17, rồi container sẽ không khởi động:
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.Không có dữ liệu nào bị mất, và restart cũng không khắc phục được vấn đề này. Chuyển sang major version mới của Postgres cần dump từ version cũ rồi restore vào data directory mới trên version mới. Đây là một công việc cần lập kế hoạch, trong đó stack phải dừng, không phải tác dụng phụ của việc pull image.
Nếu bạn đang chuyển một cài đặt Planka 1.x hiện có thay vì cài mới, bản nâng cấp đó có quy trình được ghi rõ trong tài liệu của project, và không có cách quay lại version 1 nếu trước đó bạn chưa tạo backup.
Các chế độ lỗi và chuỗi bạn sẽ thấy
Planka khởi động lại liên tục và log nhắc đến database. Thông tin xác thực trong DATABASE_URL không khớp với môi trường Postgres. Lưu ý rằng POSTGRES_PASSWORD chỉ được áp dụng khi data directory được khởi tạo lần đầu, nên sửa biến này sau lần boot đầu tiên bị lỗi sẽ không thay đổi gì. Bạn phải xóa volume db-data rồi khởi động lại.
Đăng nhập thành công nhưng board không bao giờ tải. BASE_URL không khớp với địa chỉ trên thanh địa chỉ của browser, hoặc TRUST_PROXY bị thiếu. Console của browser hiển thị các request đến /socket.io/ bị lỗi.
Upload bị lỗi trong khi mọi thứ khác vẫn hoạt động. Bind mount thuộc sở hữu của root. Chạy sudo chown -R 1000:1000 trên thư mục của host rồi restart container.
Attachment biến mất sau khi upgrade. /app/data không nằm trên volume, nên các file được lưu trong container layer mà bản upgrade đã thay thế. Khôi phục file từ backup, sau đó thêm volume trước khi thay đổi image tag lần nữa.
Traefik trả về 404. Container không nằm trên network proxy, hoặc rule Host() không khớp với DNS record của bạn. docker compose config hiển thị các label sau khi substitution, đây là nơi typo trở nên rõ ràng.
Notification hoặc webhook không bao giờ đến. Planka 2 gửi các HTTP request đi qua một filter nội bộ, và block list mặc định bao gồm localhost và postgres. Webhook trỏ đến một container khác trên cùng host có thể bị chặn theo thiết kế. Điều chỉnh OUTGOING_ALLOWED_HOSTS thay vì xóa filter.
Khi đã chạy ổn định, công việc vận hành không nhiều. Theo dõi release note và dump database trước mỗi lần upgrade. Reboot sẽ tự khởi động lại stack nhờ restart: unless-stopped, miễn là Docker service cũng được enable khi boot; Các Compose stack tự khởi động lại sau khi reboot trình bày các trường hợp không đáp ứng điều kiện này.
FAQ
Tại sao Planka tải mãi sau khi tôi đăng nhập?
Thông tin đăng nhập đã được chấp nhận nhưng kết nối realtime thì chưa. Planka tạo URL WebSocket từ BASE_URL. Vì vậy, nếu biến đó vẫn là http://localhost:3000 trong khi bạn truy cập site tại https://kanban.example.com, trình duyệt sẽ cố mở socket đến một địa chỉ không tồn tại trên máy của bạn. Developer console sẽ hiển thị các request thất bại đến /socket.io/. Đặt BASE_URL thành địa chỉ public chính xác, không có dấu gạch chéo ở cuối. Thêm TRUST_PROXY=true để app dùng header X-Forwarded-Proto từ reverse proxy, sau đó chạy docker compose up -d.
Làm cách nào để tạo user admin Planka đầu tiên?
Từ version 1.13, Planka không còn tự động tạo administrator. Bạn có thể đặt DEFAULT_ADMIN_EMAIL cùng các biến password, name và username tương ứng rồi start stack, hoặc chạy docker compose run --rm planka npm run db:create-admin-user và trả lời các prompt. Trên server dùng chung, command tương tác an toàn hơn vì password không đi vào môi trường của container, nơi docker inspect có thể đọc được. Giữ DEFAULT_ADMIN_EMAIL sau đó sẽ khóa account đó, không cho chỉnh sửa hoặc xóa từ interface.
Planka lưu attachment và avatar ở đâu?
Trong Planka 2, mọi file được upload đều nằm dưới /app/data bên trong container, bao gồm attachment, avatar người dùng và background của board. Hãy mount path này vào một named volume. Nếu volume không được mount, các file sẽ nằm trong writable layer của container và bị xóa vào lần tiếp theo container được tạo lại. Việc này xảy ra sau mỗi lần upgrade image. Bind mount cũng dùng được, nhưng Node process chạy với UID 1000. Vì vậy, hãy chạy sudo chown -R 1000:1000 trên thư mục host, nếu không upload sẽ lỗi permission.
Planka tự host cần bao nhiêu RAM?
Project không công bố mức phần cứng tối thiểu. Mức 2 vCPU và 4 GB thường xuất hiện trên các trang hosting là cấu hình mặc định của provider, không phải số liệu đo thực tế, và khá dư cho một board nhỏ. Toàn bộ workload chỉ gồm một Node process và một Postgres process. Vì vậy, plan 1 vCPU và 2 GB có thể phục vụ team từ hai đến năm người. Chạy docker stats --no-stream sau một tuần sử dụng bình thường và chọn cấu hình dựa trên số liệu của chính bạn. Hãy theo dõi disk kỹ hơn memory, vì attachment mới là phần tăng dung lượng.
Làm cách nào để upgrade Planka mà không mất dữ liệu?
Hãy dump database và archive uploads volume ngay trước khi upgrade, không dùng bản backup theo lịch của đêm trước. Dùng docker compose exec -T postgres pg_dump -U planka -d planka > planka-db.sql và giữ -T để pseudo-terminal không làm hỏng output được redirect. Đọc release notes của mọi version bị bỏ qua. Đổi image tag sang một release cụ thể thay vì latest, sau đó chạy docker compose pull và docker compose up -d rồi monitor log để theo dõi migration. Giữ Postgres tag cố định ở major version của nó, vì server sẽ từ chối mở data directory được ghi bởi một major version khác.