Plane is an open source project management platform positioned against Jira, Linear, and ClickUp. It handles work items, cycles, modules, pages, and intake. The Community Edition is AGPL-3.0 and self-hosts through Docker Compose with no user limits.
Be aware of what you are signing up for: Plane is not a single container. The Community Edition stack runs a Django API, Celery workers, a beat scheduler, a live/realtime service, three Next.js frontends (web, admin, space), a Caddy proxy, PostgreSQL, Valkey, RabbitMQ, and MinIO. That is roughly a dozen containers. Size accordingly.
This guide covers Plane Community Edition v1.3.x on Ubuntu 24.04 LTS, fronted by Nginx with Let's Encrypt.
1. Pick the right RamNode plan
Plane's docs list 4 GB RAM as the minimum and 8 GB as the production recommendation. Take the recommendation seriously. At 4 GB the migrator container tends to get OOM-killed mid-upgrade, which leaves you with a half-migrated schema and a broken instance.
| Users | RamNode sizing | Notes |
|---|---|---|
| 1 to 5, evaluation | 4 GB RAM, 2 vCPU, 60 GB SSD | Works, but expect slow first loads |
| 5 to 25, production | 8 GB RAM, 4 vCPU, 100 GB+ SSD | The realistic floor |
| 25+ | 16 GB RAM, 6+ vCPU, NVMe | Move Postgres and object storage off-box |
Order a KVM VPS, not an OpenVZ container. Plane needs a real kernel for Docker.
Disk grows from three directions: Postgres, MinIO uploads, and Docker image layers (the Plane images alone run several GB). Attach RamNode block storage if you expect heavy attachment use, and mount it under the Docker data root rather than symlinking volumes around.
Choose the location closest to your team. Plane's editor is chatty over websockets and latency is felt directly.
2. Prepare the VPS
SSH in as root and start with the basics.
apt update && apt upgrade -y
apt install -y curl ca-certificates ufw fail2ban
timedatectl set-timezone UTCCreate a non-root user with sudo and Docker access:
adduser deploy
usermod -aG sudo deployAdd swap. This is the single cheapest insurance policy on a Plane box, especially during upgrades when the migrator and the old containers overlap:
fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
sysctl -w vm.swappiness=10
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.confSet the firewall before anything binds to a port:
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enableDocker publishes ports by writing directly into the DOCKER-USER iptables chain, which bypasses UFW. Plane's proxy container will bind 0.0.0.0 on whatever port you give it. Section 5 handles that properly.
DNS
Create an A record pointing plane.example.com at your RamNode IPv4 address. Add the AAAA record too if your plan includes IPv6. Confirm propagation before requesting a certificate:
dig +short plane.example.com3. Install Docker
Use Docker's own repository. The docker.io package in Ubuntu's archive lags and ships an old Compose.
curl -fsSL https://get.docker.com | sh -
usermod -aG docker deploy
systemctl enable --now docker
docker compose versionYou want Compose v2.x. Plane's setup.sh calls docker compose, not docker-compose.
4. Install Plane Community Edition
Log back in as deploy.
mkdir ~/plane-selfhost && cd ~/plane-selfhost
curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh
chmod +x setup.sh
./setup.shThe script prints a menu:
Select a Action you want to perform:
1) Install (x86_64)
2) Start
3) Stop
4) Restart
5) Upgrade
6) View Logs
7) Backup Data
8) Exit
Action [2]:Enter 1 to install. This creates a plane-app directory containing docker-compose.yaml and plane.env, then downloads nothing else. Enter 8 to exit.
Do not start the stack yet. Edit the environment first.
Configure plane.env
cd plane-app
nano plane.envThe keys that matter for a reverse-proxied deployment:
# Bind Plane's own proxy to a high port. Nginx owns 80 and 443.
LISTEN_HTTP_PORT=8080
LISTEN_HTTPS_PORT=8443
# The public URL users will actually type. No port suffix, because
# Nginx terminates on 443 and proxies down to 8080.
WEB_URL=https://plane.example.com
CORS_ALLOWED_ORIGINS=https://plane.example.com
# Generate a real one. Do not ship the default.
SECRET_KEY=<paste output of the command below>
# Telemetry off if you prefer
ENABLE_SIGNUP=1Generate the secret:
openssl rand -hex 32WEB_URL and CORS_ALLOWED_ORIGINS are the two fields that break most installs. If they do not exactly match the scheme and hostname the browser sees, you get a login page that accepts your credentials and then bounces you straight back to it, with CORS errors in the console and nothing useful in the API logs.
Also review the generated Postgres, RabbitMQ, and MinIO credentials in the same file. setup.sh writes defaults. Replace them now, before first boot, because changing them later means recreating the volumes.
Start it
./setup.sh
# choose 2) StartFirst run pulls several GB of images and then runs the migrator. Watch it:
docker compose -f plane-app/docker-compose.yaml logs -f migratorThe migrator must exit 0 before the API is usable. If it exits non-zero, do not restart the stack in a loop hoping it resolves. Read the traceback. Almost always it is OOM or a Postgres connection refused because the DB container was not ready.
Confirm the stack:
docker compose -f plane-app/docker-compose.yaml ps
curl -I http://127.0.0.1:80805. Lock the Docker-published port to localhost
Plane's proxy binds 0.0.0.0:8080 by default, which means your instance is reachable over plain HTTP on http://YOUR-IP:8080 regardless of UFW. Fix it at the source in docker-compose.yaml:
proxy:
ports:
- "127.0.0.1:${LISTEN_HTTP_PORT}:80"Then restart and verify from a machine that is not the VPS:
curl -m 5 -I http://YOUR-VPS-IP:8080That must time out or be refused. If it returns a Plane page, you have an unauthenticated HTTP endpoint exposed to the internet.
Belt and braces, add a DOCKER-USER rule:
iptables -I DOCKER-USER -p tcp --dport 8080 ! -s 127.0.0.1 -j DROPPersist it with iptables-persistent if you rely on it.
6. Nginx and Let's Encrypt
sudo apt install -y nginx certbot python3-certbot-nginxCreate /etc/nginx/sites-available/plane:
server {
listen 80;
server_name plane.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name plane.example.com;
# certbot fills these in
client_max_body_size 100M;
# Plane's live service uses websockets for the collaborative editor
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $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 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}client_max_body_size must be at least as large as the attachment limit you intend to allow, or uploads fail with a 413 that Plane surfaces as a generic error. proxy_read_timeout at 3600 keeps the live editor's websocket from being cut every 60 seconds.
Enable and certify:
sudo ln -s /etc/nginx/sites-available/plane /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d plane.example.com
sudo systemctl list-timers | grep certbot7. First login and God Mode
Visit https://plane.example.com. The first account you register becomes the instance admin.
The admin panel lives at https://plane.example.com/god-mode/. From there you configure authentication providers, SMTP, and instance settings without touching plane.env.
Immediately after creating your admin account, set ENABLE_SIGNUP=0 in plane.env and restart, unless you actually want open registration on a public hostname. Invite users instead.
8. SMTP on RamNode
RamNode does not permit mail services on their VPS plans, and outbound port 25 is blocked. This does not stop Plane from sending mail, it just means you relay through a third party on the submission port.
Configure SMTP in God Mode rather than the env file. Use an external transactional provider over port 587 with TLS. Point the from-address at a domain you control and add the provider's SPF and DKIM records at your DNS host.
Plane will run without SMTP, but magic-link login, password reset, and invitation emails all break silently. Configure it before you invite anyone.
9. Backups
Three data stores matter. Valkey is a cache and does not need backup.
setup.sh has a built-in option 7 that tars pgdata, redisdata, and uploads. It works, but it stops the stack. For unattended nightly backups, script it:
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%Y%m%d-%H%M)
DEST=/var/backups/plane
COMPOSE="/home/deploy/plane-selfhost/plane-app/docker-compose.yaml"
mkdir -p "$DEST"
# Postgres, online, no downtime
docker compose -f "$COMPOSE" exec -T plane-db \
pg_dump -U plane -d plane | gzip > "$DEST/plane-db-$STAMP.sql.gz"
# MinIO uploads
docker run --rm \
-v plane-app_uploads:/data:ro \
-v "$DEST":/backup \
alpine tar czf "/backup/plane-uploads-$STAMP.tar.gz" -C /data .
# Config
cp /home/deploy/plane-selfhost/plane-app/plane.env "$DEST/plane.env-$STAMP"
find "$DEST" -type f -mtime +14 -deleteCheck the actual volume name with docker volume ls first. It is prefixed by the Compose project directory.
sudo chmod 700 /usr/local/bin/plane-backup.sh
sudo crontab -e
# 0 3 * * * /usr/local/bin/plane-backup.sh >> /var/log/plane-backup.log 2>&1Push a copy off the VPS. A backup on the same disk as the database is not a backup. Test a restore into a scratch instance at least once, before you need it.
10. Upgrades
cd ~/plane-selfhost
curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh
chmod +x setup.sh
./setup.sh
# choose 5) UpgradeBack up first. Every time.
The upgrade stops services, pulls new images, downloads a fresh docker-compose.yaml and a variables-upgrade.env, and leaves your plane.env untouched. Diff the two env files afterward and copy across any new keys:
cd plane-app
diff <(grep -o '^[A-Z_]*' variables-upgrade.env | sort) \
<(grep -o '^[A-Z_]*' plane.env | sort)Missing new variables is the most common cause of "upgraded fine, feature X is broken."
Then start, and watch the migrator. A failed migrator is the classic Plane upgrade failure: the containers come up, the UI loads, and new features silently do not work until someone notices weeks later.
Pin APP_RELEASE in plane.env to a specific tag rather than tracking stable if you want deterministic deploys.
11. Troubleshooting
Login loops back to the sign-in page. WEB_URL or CORS_ALLOWED_ORIGINS does not match the browser's origin exactly. Check scheme, hostname, and port. Restart after fixing.
Migrator exited with code 137. Out of memory. Add swap, or move to a larger plan. 137 is SIGKILL from the OOM killer.
Migrator exited with a Postgres connection error. The DB container was not ready. Restart the migrator alone: docker compose -f plane-app/docker-compose.yaml up migrator.
Attachments fail at a certain size. client_max_body_size in Nginx, then the MinIO/S3 limits. Nginx first.
Editor loses changes or shows a reconnect banner. Websocket upgrade headers missing in Nginx, or proxy_read_timeout too short.
Everything is slow and the box is swapping. You are on 4 GB. This is the expected outcome.
Images will not pull. Check docker compose -f plane-app/docker-compose.yaml logs for rate-limit errors from Docker Hub, and authenticate if needed.
Logs are per-service:
cd ~/plane-selfhost/plane-app
docker compose logs -f api
docker compose logs -f worker
docker compose logs -f proxy
docker compose statsIs Plane the right pick?
Plane is the heaviest of the common self-hosted project trackers, and it is the only one of them that genuinely competes with Jira on feature depth. If you need cycles, modules, epics, intake, and a real API, it earns the 8 GB.
If you need a to-do list with a Kanban view, deploy Vikunja on a 2 GB instance and spend the difference on backups.
