Skip to main content

Self-Host Plane: Docker Compose on an 8 GB VPS

TL;DR

  • Plane: AGPL-3.0 project management, ~59.1k GitHub stars, v1.4.2 (2026-08-23), $4M seed (OSS Capital, 2023)
  • Stack: 6 Plane containers + PostgreSQL 16 + Redis + MinIO + Caddy, measured 1.8 GB idle on 4 vCPU / 8 GB — one docker-compose.yml, one docker compose up -d
  • Issues, Cycles (sprints), Modules, Views, and Pages: a credible Linear and Jira alternative
  • Linear: $10/user/mo (Basic, billed yearly); Jira: $900/yr for up to 10 users (Standard, annual — ~$7.50/user/mo; the per-user rate slides with seat count); Asana: $24.99/user/mo (Advanced, annual); Plane on Liquid Web: $100/mo (8 GB VPS; $50/mo promo, first 2 months)

Plane (github.com/makeplane/plane) is an open-source project management platform that covers the core Linear and Jira workflows: Issues with custom states and priorities, Cycles for time-boxed sprints, Modules for grouping related work, Views for saved filters, and Pages for lightweight in-app documentation.

Stack complexity note: Plane runs more containers than most self-hosted tools in this series. The full stack (Django API, Celery worker, Celery beat scheduler, Next.js frontend, Next.js spaces, admin panel, PostgreSQL, Redis, and MinIO) idles at around 1.8 GB RAM. A Liquid Web 8 GB Managed VPS is the minimum recommended tier; a 4 GB VPS will run it but with little headroom for other workloads. If you'd rather manage the stack through a UI than raw docker compose commands, see Coolify, a self-hosted deployment platform that wraps Docker Compose.

Version note (updated 2026-09-09): this guide's docker-compose.yml is tested against Plane v0.25.0. The current release is v1.4.2 (2026-08-23) — a full major version ahead. Check Plane's release notes for breaking changes before you deploy v1.x in production; see "Upgrading Plane" below for the safe path.

What Plane doesn't cover: Plane is a project tracking tool, not a CRM, wiki, or communication tool. For team documentation alongside Plane, pair it with Outline. For customer support, see Chatwoot.

Prerequisites​

  • A Liquid Web 8 GB Managed VPS (minimum: 4 vCPU / 8 GB RAM / NVMe): verify pricing
  • Docker Engine 25+ and Docker Compose V2
  • A domain with its A record pointing to the VPS IP
  • About 45 minutes

The complete docker-compose.yml​

# plane/docker-compose.yml
# Tested 2026-05-02 on local Docker (4 vCPU / 8 GB RAM)
# Idle: 1.75 GB RAM | Peak: 2.56 GB (5 concurrent users, 50-issue import)
# Source: https://github.com/makeplane/plane — adapted for production

services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: plane
POSTGRES_USER: plane
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U plane -d plane"]
interval: 10s
timeout: 5s
retries: 5
networks:
- plane_net

redis:
image: redis:alpine
restart: unless-stopped
command: redis-server --save "" --appendonly no
networks:
- plane_net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5

minio:
image: minio/minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- minio_data:/data
networks:
- plane_net
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:9000/minio/health/live || exit 1"]
interval: 15s
timeout: 5s
retries: 5

plane-api:
image: makeplane/plane-backend:v0.25.0
restart: unless-stopped
command: ./bin/docker-entrypoint-api.sh
environment:
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://plane:${POSTGRES_PASSWORD}@db:5432/plane
REDIS_URL: redis://redis:6379
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
AWS_S3_ENDPOINT_URL: http://minio:9000
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
WEB_URL: ${CORS_ALLOWED_ORIGINS}
DEBUG: "0"
SENTRY_DSN: ""
DJANGO_SETTINGS_MODULE: plane.settings.production
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
networks:
- plane_net

plane-worker:
image: makeplane/plane-backend:v0.25.0
restart: unless-stopped
command: ./bin/docker-entrypoint-worker.sh
environment:
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://plane:${POSTGRES_PASSWORD}@db:5432/plane
REDIS_URL: redis://redis:6379
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
AWS_S3_ENDPOINT_URL: http://minio:9000
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
DEBUG: "0"
DJANGO_SETTINGS_MODULE: plane.settings.production
depends_on:
- plane-api
networks:
- plane_net

plane-beat:
image: makeplane/plane-backend:v0.25.0
restart: unless-stopped
command: ./bin/docker-entrypoint-beat.sh
environment:
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://plane:${POSTGRES_PASSWORD}@db:5432/plane
REDIS_URL: redis://redis:6379
DEBUG: "0"
DJANGO_SETTINGS_MODULE: plane.settings.production
depends_on:
- plane-api
networks:
- plane_net

plane-web:
image: makeplane/plane-frontend:v0.25.0
restart: unless-stopped
environment:
NEXT_PUBLIC_API_BASE_URL: ${CORS_ALLOWED_ORIGINS}
NEXT_PUBLIC_DEPLOY_URL: ${CORS_ALLOWED_ORIGINS}
networks:
- plane_net

plane-space:
image: makeplane/plane-space:v0.25.0
restart: unless-stopped
environment:
NEXT_PUBLIC_API_BASE_URL: ${CORS_ALLOWED_ORIGINS}
NEXT_PUBLIC_DEPLOY_URL: ${CORS_ALLOWED_ORIGINS}
networks:
- plane_net

plane-admin:
image: makeplane/plane-admin:v0.25.0
restart: unless-stopped
environment:
NEXT_PUBLIC_API_BASE_URL: ${CORS_ALLOWED_ORIGINS}
NEXT_PUBLIC_DEPLOY_URL: ${CORS_ALLOWED_ORIGINS}
networks:
- plane_net

caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- plane-web
- plane-api
networks:
- plane_net

volumes:
db_data:
minio_data:
caddy_data:
caddy_config:

networks:
plane_net:
driver: bridge

Caddyfile​

The Caddy config proxies different URL prefixes to the correct Plane container. The frontend, API, spaces, and admin panel all share a single domain but route by path.

# Caddyfile — Plane reverse proxy
# All services share one domain; routing is by path prefix.

yourdomain.com {
encode gzip

header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
}

# API routes
handle /api/* {
reverse_proxy plane-api:8000
}

# Auth routes (handled by backend)
handle /auth/* {
reverse_proxy plane-api:8000
}

# Spaces (public project views)
handle /spaces/* {
reverse_proxy plane-space:3001
}

# God mode / admin panel
handle /god-mode/* {
reverse_proxy plane-admin:3002
}

# Everything else goes to the frontend
handle {
reverse_proxy plane-web:3000
}
}

.env file​

# .env — keep out of version control

# Plane image version — check github.com/makeplane/plane/releases
PLANE_VERSION=v0.25.0

# PostgreSQL password
POSTGRES_PASSWORD=change-me-strong-password

# Django secret key — generate with: python -c "import secrets; print(secrets.token_urlsafe(60))"
# Must be at least 50 characters
SECRET_KEY=

# MinIO credentials (used as S3-compatible object storage for file uploads)
MINIO_ROOT_USER=plane-minio-user
MINIO_ROOT_PASSWORD=change-me-minio-password

# S3 bucket name (MinIO bucket — must be created on first run)
AWS_S3_BUCKET_NAME=uploads

# Max file upload size in bytes (default 5 MB = 5242880)
FILE_SIZE_LIMIT=5242880

# Your public domain — used for CORS and Next.js public vars
# Must include https:// and NO trailing slash
CORS_ALLOWED_ORIGINS=https://yourdomain.com

First-run setup​

# 1. Generate a SECRET_KEY (50+ chars)
python3 -c "import secrets; print(secrets.token_urlsafe(60))"
# Paste the output into SECRET_KEY in .env

# 2. Set strong passwords for POSTGRES_PASSWORD and MINIO_ROOT_PASSWORD

# 3. Set CORS_ALLOWED_ORIGINS to your domain (e.g. https://plane.yourdomain.com)

# 4. Start infrastructure services first
docker compose up -d db redis minio
docker compose ps # wait until all three are healthy

# 5. Create the MinIO uploads bucket
docker compose exec minio mc alias set local http://minio:9000 \
$(grep MINIO_ROOT_USER .env | cut -d= -f2) \
$(grep MINIO_ROOT_PASSWORD .env | cut -d= -f2)
docker compose exec minio mc mb local/uploads

# 6. Run Django migrations (required before starting plane-api)
docker compose run --rm plane-api python manage.py migrate

# 7. Start all Plane services
docker compose up -d plane-api plane-worker plane-beat plane-web plane-space plane-admin

# 8. Start Caddy
docker compose up -d caddy

# 9. Verify all containers are running
docker compose ps

# 10. Create your admin/default user
# Option A — via admin panel at https://yourdomain.com/god-mode/
# Option B — via management command:
docker compose run --rm plane-api python manage.py create_default_user

# 11. Open https://yourdomain.com and sign in
docker compose logs plane-api --tail 30 # check for errors

Verify the stack​

# All containers should show Up
docker compose ps

# API health check
curl -s https://yourdomain.com/api/health/
# {"status": "ok"}

# Resource usage
docker stats --no-stream

# Check worker is processing tasks (look for [task] messages)
docker compose logs plane-worker --tail 20

Runtime footprint​

Measured 2026-05-02 on local Docker (4 vCPU / 8 GB RAM).

ServiceIdle RAMPeak RAM (5 users, 50-issue import)
plane-api (Django)350 MB550 MB
plane-worker (Celery)200 MB350 MB
plane-beat (Celery beat)150 MB180 MB
plane-web (Next.js)300 MB450 MB
plane-space (Next.js)200 MB280 MB
plane-admin (Next.js)150 MB200 MB
db (postgres 16)200 MB280 MB
redis60 MB70 MB
minio120 MB180 MB
caddy15 MB20 MB
Total1,745 MB2,560 MB

Plane is the heaviest stack in this guide series by container count. A Liquid Web 8 GB Managed VPS handles idle load with roughly 5.5 GB to spare, enough for OS overhead, Docker, and moderate peak traffic. A 4 GB VPS is marginal and not recommended for production.

Upgrading Plane​

If you're moving from v0.25.0 (what this guide tests) to the current v1.4.2 line, read the release notes for every major version in between first — a jump across a major version can change environment variables or add migrations this guide's command sequence doesn't cover.

# Check github.com/makeplane/plane/releases for the latest version

# Update PLANE_VERSION in .env
# Then update all image tags in docker-compose.yml to the new version

# Pull all new images
docker compose pull

# Stop all Plane app containers (keep db, redis, minio running)
docker compose stop plane-api plane-worker plane-beat plane-web plane-space plane-admin caddy

# Run migrations for the new version
docker compose run --rm plane-api python manage.py migrate

# Restart everything
docker compose up -d

# Verify
docker compose ps
docker compose logs plane-api --tail 30

Cost vs Linear / Jira / Asana​

Linear BasicJira StandardAsana AdvancedPlane on Liquid Web
Monthly cost$10/user/mo$7.50/user/mo (1-10 seat tier, annual)$24.99/user/mo$100/mo (8 GB VPS)
10-user cost$100/mo$75/mo ($900/yr)$249.90/mo$100/mo
Issues / tickets✓✓✓✓
Sprints / cycles✓✓✓✓
Custom states✓✓✓✓
API✓✓✓✓
Data ownershipLinear'sAtlassian'sAsana'sYours
LicenseProprietaryProprietaryProprietaryAGPL-3.0

Jira uses progressive per-user pricing, so the rate moves with team size: Atlassian's own pricing page shows Standard at $900/yr for the 1-10 user tier (annual billing) and $9.05/user/mo at 10 seats on monthly billing, dropping to a blended $7.91/user/mo at its default 300-seat setting. The table uses the annual 10-seat figure to match the Linear and Asana columns. The Plane column uses Liquid Web's $100/mo regular renewal price, not the $50/mo first-two-months promo, because that's the number to actually budget against; it's flat regardless of team size, so the gap over per-seat pricing widens as headcount grows. All figures checked 2026-09-09 — verify current numbers at linear.app/pricing, atlassian.com/software/jira/pricing, asana.com/pricing, and liquidweb.com/vps-hosting/managed-vps/.

When this isn't right for you​

  • You need a 2 GB or 4 GB VPS only. Plane's full stack idles at 1.8 GB. On a 4 GB VPS it fits, but leaves little room for OS overhead and peak spikes. Budget for 8 GB minimum for comfortable production use.
  • You need Gantt charts or timeline views. Plane has roadmap views in early development, but it doesn't have a full Gantt chart editor. If timeline-based project planning is central to your workflow, consider Jira or ProjectLibre instead.
  • You need enterprise SSO out of the box. Plane's AGPL-3.0 edition supports basic authentication but does not include enterprise SAML SSO in the self-hosted version. SSO is available in Plane's paid cloud tiers. For self-hosted SSO, you can front Plane with an identity proxy, but it requires additional setup.
  • Your team doesn't need issue tracking. If your primary need is a team wiki or documentation hub rather than structured issue/project tracking, Outline is a better fit with a lighter footprint (500 MB idle vs 1.8 GB).

Exit strategy​

# Export issues as CSV from the Plane UI:
# Project → Issues → Filters → Export as CSV

# Dump the PostgreSQL database (contains all issues, cycles, modules, users)
docker compose exec db pg_dump -U plane plane > plane_export.sql

# Back up MinIO file storage (attachments and uploads)
docker run --rm \
-v plane_minio_data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/plane_minio_backup.tar.gz -C /data .

Plane stores all structured data (issues, states, members, cycles, modules) in PostgreSQL. File attachments are stored in MinIO. A pg_dump plus MinIO volume backup captures the full state of the instance.

Frequently Asked Questions

Linear is faster and more polished in its UI: keyboard shortcuts, command palette, and issue detail views feel more refined. Plane is catching up rapidly — it has shipped a full major version (now v1.4.2) since this guide's tested v0.25.0 — and covers the same core workflows: issues, sprints/cycles, priorities, assignees, and custom states. The main practical differences for self-hosters: Plane runs on your infrastructure (AGPL-3.0), Linear does not offer self-hosting. If UI polish is the priority and cloud hosting is acceptable, Linear wins. If data ownership or cost at team scale matters, Plane is the practical alternative.

The god-mode panel (/god-mode/) is Plane's instance-level administration UI, where you configure instance settings, manage authentication providers, set up email, and control workspace creation. On first run, it's the easiest way to create your admin account and configure your instance. After initial setup, most users never return to it. It's proxied by Caddy to /god-mode/ in this guide's Caddyfile, so it's accessible but not publicly advertised.

Yes, but you need to configure SMTP settings. Add the following to your .env and docker-compose.yml environment for plane-api: EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, and DEFAULT_FROM_EMAIL. For a self-hosted SMTP server, see the BillionMail guide in the CRM & GTM section. For transactional email, services like AWS SES, Postmark, or Resend work well.

AGPL-3.0 requires that if you distribute software that incorporates Plane's code, you must release that software under AGPL-3.0 as well. For self-hosting Plane as an internal project management tool, which is the scenario this guide covers, AGPL-3.0 imposes no restrictions. You're running it for your own team, not distributing it. The license only affects you if you modify Plane's source code and offer the modified version to external users as a service.

Common mistakes and fixes

Plane API container fails with 'relation does not exist'.

Database migrations haven't run yet. Execute: docker compose run --rm plane-api python manage.py migrate, then restart all Plane containers with docker compose restart. This error appears on first start or after an upgrade where new migrations were introduced.

File uploads fail with 'Connection refused' or 'Connection timeout' to MinIO.

Confirm AWS_S3_ENDPOINT_URL is set to http://minio:9000 (the Docker service name, not localhost or 127.0.0.1). Also verify the uploads bucket exists; create it manually: docker compose exec minio mc alias set local http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && docker compose exec minio mc mb local/uploads. If mc isn't available in the MinIO container, use the MinIO web console at port 9001 to create the bucket.

Frontend shows a blank page with 'Network Error' in the browser console.

NEXT_PUBLIC_API_BASE_URL must exactly match your actual domain including the https:// scheme and no trailing slash. For example: NEXT_PUBLIC_API_BASE_URL=https://plane.yourdomain.com. A mismatch (wrong scheme, extra slash, or wrong subdomain) causes all API calls to fail silently, producing a blank frontend.