Self-Host Plane on Liquid Web
TL;DR
- Plane: AGPL-3.0 project management, ~46k GitHub stars, v0.25.0 (2026-03-20), YC W22 + Series A funded
- Stack: 6 Plane containers + PostgreSQL 16 + Redis + MinIO + Caddy, measured 1.8 GB idle on 4 vCPU / 8 GB
- Issues, Cycles (sprints), Modules, Views, and Pages: a credible Linear and Jira alternative
- Linear: $8/user/mo; Jira: $7.75/user/mo; Asana: $10.99/user/mo; Plane on Liquid Web: ~$30/mo flat
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. The UI is clean and fast, noticeably more modern than Jira and comparable to Linear's visual design.
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.
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.8 GB RAM | Peak: 2.5 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).
| Service | Idle RAM | Peak RAM (5 users, 50-issue import) |
|---|---|---|
| plane-api (Django) | 350 MB | 550 MB |
| plane-worker (Celery) | 200 MB | 350 MB |
| plane-beat (Celery beat) | 150 MB | 180 MB |
| plane-web (Next.js) | 300 MB | 450 MB |
| plane-space (Next.js) | 200 MB | 280 MB |
| plane-admin (Next.js) | 150 MB | 200 MB |
| db (postgres 16) | 200 MB | 280 MB |
| redis | 60 MB | 70 MB |
| minio | 120 MB | 180 MB |
| caddy | 15 MB | 20 MB |
| Total | 1,745 MB | 2,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
# 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 Standard | Jira Standard | Asana Advanced | Plane on Liquid Web | |
|---|---|---|---|---|
| Monthly cost | $8/user/mo | $7.75/user/mo | $10.99/user/mo | ~$30/mo flat (8 GB VPS) |
| 10-user cost | $80/mo | $77.50/mo | $109.90/mo | ~$30/mo |
| Issues / tickets | ✓ | ✓ | ✓ | ✓ |
| Sprints / cycles | ✓ | ✓ | ✓ | ✓ |
| Custom states | ✓ | ✓ | ✓ | ✓ |
| API | ✓ | ✓ | ✓ | ✓ |
| Data ownership | Linear's | Atlassian's | Asana's | Yours |
| License | Proprietary | Proprietary | Proprietary | AGPL-3.0 |
Prices subject to change, so verify 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.
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 (v0.25.0 introduced significant UI improvements) 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.



