Project Management
    Docker Compose

    Deploy Plane on a VPS

    Deploy Plane Community Edition — an open source Jira/Linear alternative with work items, cycles, modules, and pages — on a RamNode KVM VPS with Docker Compose and Nginx TLS.

    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.

    UsersRamNode sizingNotes
    1 to 5, evaluation4 GB RAM, 2 vCPU, 60 GB SSDWorks, but expect slow first loads
    5 to 25, production8 GB RAM, 4 vCPU, 100 GB+ SSDThe realistic floor
    25+16 GB RAM, 6+ vCPU, NVMeMove 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.

    shell
    apt update && apt upgrade -y
    apt install -y curl ca-certificates ufw fail2ban
    timedatectl set-timezone UTC

    Create a non-root user with sudo and Docker access:

    shell
    adduser deploy
    usermod -aG sudo deploy

    Add swap. This is the single cheapest insurance policy on a Plane box, especially during upgrades when the migrator and the old containers overlap:

    shell
    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.conf

    Set the firewall before anything binds to a port:

    shell
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow OpenSSH
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw enable

    Docker 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:

    shell
    dig +short plane.example.com

    3. Install Docker

    Use Docker's own repository. The docker.io package in Ubuntu's archive lags and ships an old Compose.

    shell
    curl -fsSL https://get.docker.com | sh -
    usermod -aG docker deploy
    systemctl enable --now docker
    docker compose version

    You want Compose v2.x. Plane's setup.sh calls docker compose, not docker-compose.


    4. Install Plane Community Edition

    Log back in as deploy.

    shell
    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.sh

    The script prints a menu:

    shell
    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

    shell
    cd plane-app
    nano plane.env

    The keys that matter for a reverse-proxied deployment:

    shell
    # 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=1

    Generate the secret:

    shell
    openssl rand -hex 32

    WEB_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

    shell
    ./setup.sh
    # choose 2) Start

    First run pulls several GB of images and then runs the migrator. Watch it:

    shell
    docker compose -f plane-app/docker-compose.yaml logs -f migrator

    The 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:

    shell
    docker compose -f plane-app/docker-compose.yaml ps
    curl -I http://127.0.0.1:8080

    5. 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:

    shell
      proxy:
        ports:
          - "127.0.0.1:${LISTEN_HTTP_PORT}:80"

    Then restart and verify from a machine that is not the VPS:

    shell
    curl -m 5 -I http://YOUR-VPS-IP:8080

    That 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:

    shell
    iptables -I DOCKER-USER -p tcp --dport 8080 ! -s 127.0.0.1 -j DROP

    Persist it with iptables-persistent if you rely on it.


    6. Nginx and Let's Encrypt

    shell
    sudo apt install -y nginx certbot python3-certbot-nginx

    Create /etc/nginx/sites-available/plane:

    shell
    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:

    shell
    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 certbot

    7. 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:

    shell
    #!/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 -delete

    Check the actual volume name with docker volume ls first. It is prefixed by the Compose project directory.

    shell
    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>&1

    Push 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

    shell
    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) Upgrade

    Back 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:

    shell
    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:

    shell
    cd ~/plane-selfhost/plane-app
    docker compose logs -f api
    docker compose logs -f worker
    docker compose logs -f proxy
    docker compose stats

    Is 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.