Applications AWS

Plane Project and Issue Tracker on AWS User Guide

| Product: Plane

Overview

Plane is an open source project and issue tracking tool - a self hosted alternative to Jira and Linear. Teams plan and track work in workspaces and projects, with work items that carry states, priorities, labels, assignees, estimates and sub items. Work items group into cycles for time boxed sprints and modules for longer streams of work, and the same backlog can be viewed as a list, a kanban board, a calendar or a spreadsheet.

This cloudimg AMI delivers Plane Community v1.3.1 fully installed and wired as a twelve container appliance behind a single reverse proxy. The web console answers on port 80 within a few minutes of launch, on infrastructure you control, with no per seat licence fee and no project data leaving your AWS account.

What is in the image

Twelve long running containers, all on a private Docker Compose bridge network. Only the proxy publishes a host port - the database, cache, queue and object store are not reachable from outside the instance at all.

Component Image Role Host port
proxy makeplane/plane-proxy:v1.3.1 reverse proxy and the single front door 80, 443
web makeplane/plane-frontend:v1.3.1 the main Plane web application none
admin makeplane/plane-admin:v1.3.1 instance administration console none
space makeplane/plane-space:v1.3.1 public shared views and pages none
live makeplane/plane-live:v1.3.1 live collaborative editing server none
api makeplane/plane-backend:v1.3.1 Django REST API none
worker makeplane/plane-backend:v1.3.1 asynchronous background worker none
beat-worker makeplane/plane-backend:v1.3.1 scheduled job runner none
plane-db postgres:15.7-alpine PostgreSQL database (all projects and work items) none
plane-redis valkey/valkey:7.2.11-alpine Valkey cache none
plane-mq rabbitmq:3.13.6-management-alpine RabbitMQ task queue none
plane-minio minio/minio (digest pinned) S3 compatible object store for attachments none

A thirteenth compose service, migrator, runs the database migration set to completion at boot and then exits - it is a job, not a long running component.

A note on names. The container is called plane-redis for upstream compatibility, but the engine it runs is Valkey, not Redis. The table above reflects what actually runs.

What cloudimg adds

  • No shared secret ever ships. The image is captured with no database, no administrator account and only placeholder secrets, and the stack is never started before capture. On your instance's first boot the database password, the Django SECRET_KEY that signs sessions, the object store access and secret keys, the queue password and the live collaboration server key are all generated on your instance, the database initialises against them, and an administrator account plus a REST API key are written to a root only file.
  • A dedicated gp3 data volume at /var/lib/plane carries the entire stateful tier: the Docker data-root, the containerd image and snapshot store, the PostgreSQL cluster, the cache, the queue and uploaded attachments. It is mounted by filesystem UUID so it reproduces on every instance, and it can be resized, snapshotted and backed up independently of the operating system disk.
  • The appliance learns its own address on every boot from EC2 instance metadata (IMDSv2) and rewrites the application URL and allowed origins, so the web console keeps working after a stop and start.
  • A ready to use workspace and project are created during that same first boot, so the tracker is usable the moment the instance comes up.
  • The base operating system is fully patched with unattended security upgrades enabled.

Licensing

Plane is free software under the GNU Affero General Public License v3.0. Verified at the shipped tag: LICENSE.txt at v1.3.1 is the AGPL v3, and COPYRIGHT.txt reads "Plane Software, Inc. and contributors ... SPDX-License-Identifier: AGPL-3.0-only". The v1.3.1 tree contains no ee/ or enterprise/ directory, so the whole shipped codebase is AGPL-3.0-only and nothing in this image is a paid, Pro or Enterprise feature.

This image ships stock, unmodified upstream Plane images, pinned by tag and by digest, configured externally through an environment file and systemd units. The Corresponding Source for the network hosted appliance, as required by AGPL section 13, is the pinned upstream release: github.com/makeplane/plane/releases/tag/v1.3.1.

The cloudimg charge covers packaging, security patching, image maintenance and support. Plane itself charges nothing.


Launching the instance

Subscribe to the listing in AWS Marketplace and launch the AMI. The recommended instance type is m5.large (2 vCPU, 8 GiB RAM) - Plane's own guidance is at least 4 GiB of RAM for the self hosted stack, and the image adds a swapfile on the data volume for the first boot migration spike.

Your security group needs:

Port Protocol Purpose
22 TCP SSH administration
80 TCP Plane web console and REST API
443 TCP HTTPS, once you terminate TLS with your own certificate

Restrict port 22, and ideally 80 and 443, to your own networks. Two EBS volumes are attached: a 50 GiB operating system root volume, and a 100 GiB gp3 data volume mounted at /var/lib/plane.

Connecting to your instance

SSH in as the login user for the OS variant you launched.

OS variant Login user Example
Ubuntu 24.04 LTS ubuntu ssh -i your-key.pem ubuntu@<public-ip>

First boot

First boot generates every secret on your own instance, initialises the database, runs the full Django migration set and creates your administrator account and seed workspace. On an m5.large this takes a few minutes. The appliance writes a sentinel file when it is finished.

sudo test -f /var/lib/cloudimg/plane-firstboot.done && echo "first boot complete"
first boot complete

Confirm the systemd units that own the appliance are all enabled:

sudo systemctl is-enabled docker.service plane-configure.service plane-firstboot.service plane.service cloudimg-plane-swap.service
enabled
enabled
enabled
enabled
enabled

plane-configure.service runs on every boot and resolves the instance's current public address from IMDSv2. plane-firstboot.service is a one shot that only ever runs once - it is gated on the sentinel above. plane.service brings the compose stack up and is gated on that same sentinel, so it is impossible for the stack to start before the per instance secrets exist.


Retrieving your credentials

Every credential is unique to your instance and is written to a root only file at /root/plane-credentials.txt.

The file holds these keys:

sudo grep -oE '^[A-Z_]+=' /root/plane-credentials.txt
PLANE_URL=
PLANE_ADMIN_EMAIL=
PLANE_ADMIN_PASSWORD=
PLANE_API_KEY=
PLANE_WORKSPACE_SLUG=
PLANE_PROJECT_ID=

Read the whole file to get the values:

sudo cat /root/plane-credentials.txt
PLANE_URL=<PLANE_URL>
PLANE_ADMIN_EMAIL=<PLANE_ADMIN_EMAIL>
PLANE_ADMIN_PASSWORD=<PLANE_ADMIN_PASSWORD>
PLANE_API_KEY=<PLANE_API_KEY>
PLANE_WORKSPACE_SLUG=<PLANE_WORKSPACE_SLUG>
PLANE_PROJECT_ID=<PLANE_PROJECT_ID>

Store these somewhere safe and change the administrator password once you have signed in. The file is mode 0600 and owned by root, so only a user who can already become root on the instance can read it.


Verifying the deployment

Every container is up

sudo docker compose -f /opt/plane/docker-compose.yaml --env-file /opt/plane/plane.env ps --format 'table {{.Service}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
SERVICE       IMAGE                                                                                 STATUS                    PORTS
admin         makeplane/plane-admin:v1.3.1                                                          Up 11 minutes (healthy)   80/tcp, 3000/tcp
api           makeplane/plane-backend:v1.3.1                                                        Up 11 minutes             8000/tcp
beat-worker   makeplane/plane-backend:v1.3.1                                                        Up 11 minutes             8000/tcp
live          makeplane/plane-live:v1.3.1                                                           Up 11 minutes             3000/tcp
plane-db      postgres:15.7-alpine                                                                  Up 11 minutes             5432/tcp
plane-minio   minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e   Up 11 minutes             9000/tcp
plane-mq      rabbitmq:3.13.6-management-alpine                                                     Up 11 minutes             4369/tcp, 5671-5672/tcp, 15671-15672/tcp, 15691-15692/tcp, 25672/tcp
plane-redis   valkey/valkey:7.2.11-alpine                                                           Up 11 minutes             6379/tcp
proxy         makeplane/plane-proxy:v1.3.1                                                          Up 11 minutes             0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp, 443/udp, 2019/tcp
space         makeplane/plane-space:v1.3.1                                                          Up 11 minutes             3000/tcp
web           makeplane/plane-frontend:v1.3.1                                                       Up 11 minutes (healthy)   80/tcp, 3000/tcp
worker        makeplane/plane-backend:v1.3.1                                                        Up 11 minutes             8000/tcp

The PORTS column is the security model in one view. Every container except proxy shows only its container port - none of them is bound to a host address, so PostgreSQL, Valkey, RabbitMQ and MinIO cannot be reached from the network even if your security group were wide open.

The API and the web console answer

curl -s -o /dev/null -w 'API /api/instances/  HTTP %{http_code}\n' http://127.0.0.1/api/instances/
curl -s -o /dev/null -w 'web UI /            HTTP %{http_code}\n' http://127.0.0.1/
API /api/instances/  HTTP 200
web UI /            HTTP 200

Only the proxy is listening on the host

sudo ss -tlnp | grep -E ':(80|443|5432|6379|9000|5672) ' | awk '{print $1, $4, $6}'
LISTEN 0.0.0.0:80 users:(("docker-proxy",pid=5055,fd=8))
LISTEN 0.0.0.0:443 users:(("docker-proxy",pid=5079,fd=8))
LISTEN [::]:80 users:(("docker-proxy",pid=5063,fd=8))
LISTEN [::]:443 users:(("docker-proxy",pid=5088,fd=8))

Ports 5432 (PostgreSQL), 6379 (Valkey), 9000 (MinIO) and 5672 (RabbitMQ) return nothing - they are not bound on the host.

The whole stateful tier is on the dedicated volume

findmnt -no SOURCE,TARGET,FSTYPE /var/lib/plane
findmnt -no SOURCE,TARGET,FSTYPE /var/lib/containerd
/dev/nvme1n1 /var/lib/plane ext4
/dev/nvme1n1[/containerd-store] /var/lib/containerd ext4

Both the Docker data-root and the containerd image store live on the data volume. This matters more than it looks: Docker 29 keeps image layers under /var/lib/containerd, not under the Docker data-root, so relocating only the data-root would leave several gigabytes of Plane images on the operating system disk. Both are bind mounted onto the data volume and both entries are recorded in /etc/fstab, so the layout reproduces on every instance.

grep -E 'plane|containerd' /etc/fstab
UUID=985b4f29-d530-4741-bf37-d250e81c2e33 /var/lib/plane ext4 defaults,nofail 0 2
/var/lib/plane/containerd-store /var/lib/containerd none bind,nofail,x-systemd.requires-mounts-for=/var/lib/plane 0 0
df -h --output=target,size,used,avail /var/lib/plane /
Mounted on      Size  Used Avail
/var/lib/plane   98G  8.2G   85G
/                48G  3.4G   45G

The shipped image set is pinned

sudo docker images --format '{{.Repository}}:{{.Tag}}' | sort
makeplane/plane-admin:v1.3.1
makeplane/plane-backend:v1.3.1
makeplane/plane-frontend:v1.3.1
makeplane/plane-live:v1.3.1
makeplane/plane-proxy:v1.3.1
makeplane/plane-space:v1.3.1
minio/minio:latest
postgres:15.7-alpine
rabbitmq:3.13.6-management-alpine
valkey/valkey:7.2.11-alpine

Every image is baked into the AMI, so your instance never pulls from a registry at first boot. The Plane images are pinned by release tag and MinIO is additionally pinned by digest in the compose file.

sudo grep '^APP_RELEASE=' /opt/plane/plane.env
APP_RELEASE=v1.3.1

Signing in to the web console

Open http://<public-ip>/ in a browser. Plane asks for your email first, then your password.

Plane sign in screen

Sign in with the PLANE_ADMIN_EMAIL and PLANE_ADMIN_PASSWORD values from the credentials file. You land straight in the seed workspace - the administrator profile is already marked onboarded, so there is no setup wizard to click through.

The seed workspace and its Getting Started project

Open a project and you are in the work item list, where each item shows its state, priority, dates, assignees and labels inline.

The work item list

Use the layout switcher, immediately left of Display, to move between List, Board and Calendar. The board groups by state and supports drag and drop between columns.

The kanban board grouped by state

Workflow states are configurable per project under project settings, grouped into the five Plane state groups - Backlog, Unstarted, Started, Completed and Cancelled.

Configuring workflow states

The instance administration console - where you configure email, authentication providers and instance wide settings - is at http://<public-ip>/god-mode/, and takes the same administrator email and password.


Using the REST API

Every instance is created with an administrative REST API key. Read it into a shell variable rather than printing it, and send it in an X-API-Key header.

List the projects in your workspace:

KEY=$(sudo grep '^PLANE_API_KEY=' /root/plane-credentials.txt | cut -d= -f2-)
SLUG=$(sudo grep '^PLANE_WORKSPACE_SLUG=' /root/plane-credentials.txt | cut -d= -f2-)
curl -s -H "X-API-Key: $KEY" "http://127.0.0.1/api/v1/workspaces/$SLUG/projects/" | jq -r '.results[] | "\(.identifier)\t\(.name)"'
GS  Getting Started

Create a work item:

KEY=$(sudo grep '^PLANE_API_KEY=' /root/plane-credentials.txt | cut -d= -f2-)
SLUG=$(sudo grep '^PLANE_WORKSPACE_SLUG=' /root/plane-credentials.txt | cut -d= -f2-)
PROJ=$(sudo grep '^PLANE_PROJECT_ID=' /root/plane-credentials.txt | cut -d= -f2-)
curl -s -X POST "http://127.0.0.1/api/v1/workspaces/$SLUG/projects/$PROJ/issues/" -H "X-API-Key: $KEY" -H 'Content-Type: application/json' -d '{"name":"Check the deployment guide end to end"}' | jq -r '"created \(.sequence_id): \(.name)"'
created 14: Check the deployment guide end to end

An unauthenticated or wrong key is rejected:

SLUG=$(sudo grep '^PLANE_WORKSPACE_SLUG=' /root/plane-credentials.txt | cut -d= -f2-)
curl -s -o /dev/null -w 'bogus key: HTTP %{http_code}\n' -H 'X-API-Key: not-a-real-key' "http://127.0.0.1/api/v1/workspaces/$SLUG/projects/"
bogus key: HTTP 403

Issue additional, scoped API keys per user from Settings > API tokens in the web console rather than sharing the bootstrap key.


Day two operations

Managing the stack

sudo systemctl status plane --no-pager | head -5

plane.service is a one shot that runs docker compose up -d. To restart the whole appliance:

sudo systemctl restart plane.service

To read the logs of a single component:

sudo docker compose -f /opt/plane/docker-compose.yaml --env-file /opt/plane/plane.env logs --tail 20 api

Using your own domain name and TLS

The appliance rewrites APP_DOMAIN, WEB_URL and CORS_ALLOWED_ORIGINS to the instance's current public IPv4 on every boot, which is what makes it work out of the box. Once you have a DNS name you want to use instead, three steps:

  1. Stop the per boot rewrite so it cannot overwrite your values: sudo systemctl disable --now plane-configure.service
  2. Edit /opt/plane/plane.env with your editor of choice, setting APP_DOMAIN=tracker.example.com, WEB_URL=https://tracker.example.com and CORS_ALLOWED_ORIGINS=https://tracker.example.com
  3. Apply it: sudo systemctl restart plane.service

Check the current values at any time:

sudo grep -E '^(APP_DOMAIN|WEB_URL|CORS_ALLOWED_ORIGINS)=' /opt/plane/plane.env

For TLS, the simplest production shape is an Application Load Balancer or CloudFront distribution in front of the instance holding an ACM certificate, forwarding to port 80. The bundled proxy also supports ACME directly through the CERT_EMAIL, CERT_ACME_CA and LISTEN_HTTPS_PORT variables in the same env file if you would rather terminate on the instance.

Outbound email

Plane sends workspace invitations and notifications by email. Configure your SMTP relay in the instance administration console at http://<public-ip>/god-mode/, under Email.

Backups

Everything stateful lives on the /var/lib/plane volume, so an EBS snapshot of that volume captures the database, the queue, the cache and every uploaded attachment together. Schedule it with AWS Backup or a Data Lifecycle Manager policy. For a logical database dump instead:

sudo docker compose -f /opt/plane/docker-compose.yaml --env-file /opt/plane/plane.env exec -T plane-db pg_dump -U plane plane | gzip > /var/tmp/plane-backup.sql.gz && ls -lh /var/tmp/plane-backup.sql.gz

Resizing the data volume

Grow the EBS volume in the console or with the CLI, then extend the filesystem in place - no downtime and no unmount:

sudo resize2fs /dev/nvme1n1 && df -h --output=target,size,avail /var/lib/plane

Patching

Unattended security upgrades are enabled for the operating system. Plane itself is pinned by image tag, so upgrading is a deliberate change: edit APP_RELEASE in /opt/plane/plane.env to the new version, pull, and bring the stack back up. Read the upstream release notes first, take a snapshot of the data volume, and note that the migrator service runs the database migrations on the way up.


Support

cloudimg provides 24/7 technical support for this product by email and live chat.

Contact: support@cloudimg.co.uk

We can help with deployment and initial configuration, creating your first workspaces and projects, inviting users and configuring roles, re-pointing the instance at your own domain, TLS termination, outbound email, attachment storage and retention, backing up the bundled database, resizing the data volume, performance tuning, and patching guidance.