Plane is a practical choice when a product, engineering, or operations team wants a private alternative to hosted issue trackers without giving up modern project views. A small agency can use it to coordinate client roadmaps, an internal platform team can track infrastructure work, and a founder can keep product planning close to customer data. This guide follows the house pattern used across SysBrix Guides: a single Ubuntu host, Docker Compose for repeatable operations, Caddy for automatic HTTPS, PostgreSQL for durable application data, Redis for queues and cache, and MinIO for file attachments.
The goal is not a demo that works once. The goal is a production-oriented baseline you can understand, back up, patch, and hand to another administrator. Replace the example domain before starting, keep secrets out of screenshots and tickets, and test restore steps before depending on the workspace for customer commitments.
Architecture and flow overview
Users connect to Caddy on ports 80 and 443. Caddy terminates TLS, renews certificates automatically, compresses responses, and routes browser traffic to the Plane frontend while forwarding API and upload paths to the backend. The backend stores structured data in PostgreSQL, uses Redis for background work and fast state, and writes uploaded assets to a private MinIO bucket. A worker process handles asynchronous jobs so requests stay responsive under normal project activity.
This layout keeps public exposure small. Only Caddy publishes host ports. PostgreSQL, Redis, MinIO, API, worker, and frontend services remain on the private Docker network. For larger teams, move PostgreSQL and object storage to managed services later, but keep this local topology for a predictable first deployment.
Prerequisites
- Ubuntu 22.04 or 24.04 server with at least 2 vCPU, 4 GB RAM, and 40 GB disk.
- A DNS A record such as
plane.example.compointing to the server. - Outbound SMTP credentials for invites, password reset, and notifications.
- SSH access with sudo privileges and a plan for off-host backups.
Before installing, confirm the host is clean and ports 80 and 443 are free. If another reverse proxy already owns those ports, either integrate Plane into that proxy or move this Compose stack to an internal network behind the existing edge.
Step-by-step deployment
Start by installing Docker from the official repository. Reconnect your SSH session after adding your user to the Docker group, or prefix Compose commands with sudo until the group membership applies.
sudo apt update && sudo apt -y upgrade
sudo apt -y install ca-certificates curl gnupg ufw git
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
If the copy button is unavailable, select the code block and copy it manually.
Create a dedicated application directory. The generated files below are intentionally separated so a future operator can rotate one credential without rewriting the whole stack.
sudo mkdir -p /opt/plane/{data,postgres,redis,minio,caddy}
sudo chown -R $USER:$USER /opt/plane
cd /opt/plane
openssl rand -hex 32 > .secret_key
openssl rand -base64 36 > .postgres_password
openssl rand -base64 36 > .minio_password
If the copy button is unavailable, select the code block and copy it manually.
Create the environment file and replace every placeholder before launch. Use the real Plane domain, a working SMTP account, and the generated secrets. Do not commit this file to Git.
cat > /opt/plane/.env <<'EOF'
PLANE_DOMAIN=plane.example.com
WEB_URL=https://plane.example.com
SECRET_KEY=replace-with-output-of-cat-/opt/plane/.secret_key
POSTGRES_DB=plane
POSTGRES_USER=plane
POSTGRES_PASSWORD=replace-with-output-of-cat-/opt/plane/.postgres_password
DATABASE_URL=postgresql://plane:${POSTGRES_PASSWORD}@postgres:5432/plane
REDIS_URL=redis://redis:6379/0
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=plane
AWS_SECRET_ACCESS_KEY=replace-with-output-of-cat-/opt/plane/.minio_password
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_BUCKET_NAME=plane-uploads
USE_MINIO=1
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
[email protected]
EMAIL_HOST_PASSWORD=replace-with-smtp-password
EMAIL_USE_TLS=1
[email protected]
EOF
chmod 600 /opt/plane/.env
If the copy button is unavailable, select the code block and copy it manually.
Now define the Compose services. Plane image names can change across upstream releases, so pin exact tags after your first successful staging test if you need strict change control. The important production pattern is stable volumes for stateful components and no direct host ports except the HTTPS proxy.
cat > /opt/plane/docker-compose.yml <<'EOF'
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
env_file: .env
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U plane -d plane"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- ./redis:/data
minio:
image: minio/minio:latest
restart: unless-stopped
env_file: .env
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: plane
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
volumes:
- ./minio:/data
api:
image: makeplane/plane-backend:latest
restart: unless-stopped
env_file: .env
depends_on:
- postgres
- redis
- minio
worker:
image: makeplane/plane-backend:latest
restart: unless-stopped
env_file: .env
command: ./bin/worker
depends_on:
- api
web:
image: makeplane/plane-frontend:latest
restart: unless-stopped
env_file: .env
depends_on:
- api
proxy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy:/data
depends_on:
- web
- api
EOF
If the copy button is unavailable, select the code block and copy it manually.
Add the Caddy configuration. If your installed Plane version expects additional API paths, keep them routed to the backend and leave the default route for the web frontend.
cat > /opt/plane/Caddyfile <<'EOF'
plane.example.com {
encode zstd gzip
reverse_proxy /api/* api:8000
reverse_proxy /auth/* api:8000
reverse_proxy /uploads/* api:8000
reverse_proxy web:3000
}
EOF
If the copy button is unavailable, select the code block and copy it manually.
Apply the real domain, inject secrets, pull images, and start the stack. Watch logs on the first boot because migrations and bucket initialization are easiest to diagnose before users sign in.
cd /opt/plane
sed -i "s|plane.example.com|your-real-domain.example|g" .env Caddyfile
sed -i "s|replace-with-output-of-cat-/opt/plane/.secret_key|$(cat .secret_key)|" .env
sed -i "s|replace-with-output-of-cat-/opt/plane/.postgres_password|$(cat .postgres_password)|" .env
sed -i "s|replace-with-output-of-cat-/opt/plane/.minio_password|$(cat .minio_password)|" .env
docker compose pull
docker compose up -d
docker compose logs -f --tail=80 api web proxy
If the copy button is unavailable, select the code block and copy it manually.
Configuration and secrets handling best practices
Treat .env, the database password, MinIO password, SMTP password, and secret key as production secrets. Store the authoritative copy in a password manager, restrict file permissions to the deployment user, and avoid pasting values into chat systems. If multiple administrators need access, grant them SSH and password-manager permissions instead of sharing raw credentials in documents.
Use a dedicated SMTP identity for Plane so delivery issues do not affect other services. For object storage, keep MinIO private to the Docker network and expose files only through Plane. If compliance requirements grow, replace local MinIO with S3-compatible storage that has retention policies, encryption controls, and centralized audit logs.
Plan updates as a maintenance event. Run a backup first, read upstream release notes, pull images, start the stack, and test login, project creation, file upload, comments, and email delivery. Avoid unattended image updates for business-critical planning tools.
Verification checklist
Enable the firewall after confirming SSH access. Then verify HTTPS, service health, and Compose state. A healthy deployment should show Caddy, web, API, worker, PostgreSQL, Redis, and MinIO running without restart loops.
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
curl -I https://your-real-domain.example
curl -fsS https://your-real-domain.example/api/health || true
docker compose ps
If the copy button is unavailable, select the code block and copy it manually.
- Open the site in a browser and create the first admin workspace.
- Create a project, issue, cycle, and module to confirm core workflow behavior.
- Upload a small attachment and verify it persists after
docker compose restart. - Send a test invite to confirm SMTP settings and spam filtering.
- Review
docker compose logs api workerfor migration, storage, or email errors.
Backups and recovery routine
Plane depends on both database records and object storage. A database-only backup preserves issues and users but loses attachments. A MinIO-only backup preserves files but not project history. Keep both, copy them off the host, and test restoration into a staging directory before assuming the backup is useful.
cat > /opt/plane/backup.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd /opt/plane
stamp=$(date +%Y%m%d-%H%M%S)
mkdir -p backups
source .env
docker compose exec -T postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip > "backups/plane-db-$stamp.sql.gz"
tar -czf "backups/plane-minio-$stamp.tar.gz" minio
tar -czf "backups/plane-config-$stamp.tar.gz" .env Caddyfile docker-compose.yml
find backups -type f -mtime +14 -delete
EOF
chmod +x /opt/plane/backup.sh
/opt/plane/backup.sh
(crontab -l 2>/dev/null; echo '15 2 * * * /opt/plane/backup.sh >/var/log/plane-backup.log 2>&1') | crontab -
If the copy button is unavailable, select the code block and copy it manually.
For recovery, stop the stack, restore the PostgreSQL dump into a fresh volume, replace the MinIO directory with the matching archive, restore the configuration files, and start Compose. The most common recovery mistake is restoring data from one timestamp with object storage from another; keep backup sets labeled and copied together.
Common issues and fixes
Caddy cannot issue a certificate. Confirm DNS points to this server, ports 80 and 443 are reachable from the internet, and no cloud firewall blocks inbound traffic. Temporarily run curl -I http://your-domain from another network to verify routing.
The frontend loads but API calls fail. Check the Caddy path routing and the WEB_URL value. Mismatched domains, missing HTTPS scheme, or an API container crash will usually show up in browser developer tools and backend logs.
Uploads fail or disappear. Validate MinIO credentials, bucket settings, and the S3 endpoint URL. Keep MinIO on the private Docker network; do not point Plane at a public console URL unless you intentionally moved storage elsewhere.
Email invites do not arrive. Check SMTP host, TLS mode, sender address alignment, and provider rate limits. Many providers require app passwords or verified sender identities before accepting automated messages.
Updates break the service. Restore the latest backup, pin the previous image tags, and test the upgrade in staging. Rolling forward blindly can compound migration problems.
Updates and maintenance
Schedule a monthly maintenance window for image updates, operating system patches, and restore drills. Keep at least one known-good Compose file and environment snapshot with each backup set. If you need stronger availability, move PostgreSQL to managed database hosting, MinIO to managed object storage, and run the Plane application tier behind a separate load balancer.
cd /opt/plane
/opt/plane/backup.sh
docker compose pull
docker compose up -d
docker compose logs --tail=120 api worker web
curl -fsS https://your-real-domain.example || exit 1
If the copy button is unavailable, select the code block and copy it manually.
FAQ
Can I run Plane on a smaller VPS?
Yes for a small team, but 2 vCPU and 4 GB RAM is a more comfortable baseline once PostgreSQL, Redis, MinIO, frontend, backend, and worker containers are all active.
Should I expose the MinIO console publicly?
No. Keep MinIO private unless you have a specific administrative need and strong access controls. Plane should talk to MinIO over the internal Docker network.
How do I make this deployment more highly available?
Externalize PostgreSQL, Redis, and object storage first. Then run multiple application instances behind a managed load balancer or orchestrator with health checks.
Can I use Nginx instead of Caddy?
Yes. The same service layout works with Nginx, but you must manage certificate issuance, renewal, and proxy headers yourself or with Certbot automation.
What should be included in backups?
Back up PostgreSQL, MinIO object data, .env, the Caddyfile, and the Compose file. Test recovery using the same timestamped set.
How often should I update Plane?
For production, review releases monthly or when a security update appears. Back up first, test in staging when possible, then update during a quiet window.
What is the safest way to rotate secrets?
Rotate one secret at a time, update the password manager, change the value in .env, restart affected services, and verify login, uploads, and email delivery before rotating the next one.
Internal links
- Deploy OpenProject with Docker Compose + Caddy + PostgreSQL + Redis
- Deploy Wekan with Docker Compose + Caddy + MongoDB
- Deploy Vikunja with Docker Compose + Caddy + PostgreSQL
Talk to us
If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.